Coverage for python/lsst/summit/utils/astrometry/utils.py: 15%

144 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-10 04:12 -0700

1# This file is part of summit_utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22 

23from typing import Any 

24 

25import astropy 

26import astropy.units as u 

27import numpy as np 

28from astropy.coordinates import AltAz, EarthLocation, SkyCoord 

29from astropy.time import Time 

30 

31import lsst.afw.geom as afwGeom 

32import lsst.afw.image as afwImage 

33import lsst.afw.table as afwTable 

34import lsst.pipe.base as pipeBase 

35from lsst.afw.geom import SkyWcs 

36from lsst.daf.base import PropertySet 

37from lsst.pipe.tasks.characterizeImage import CharacterizeImageConfig, CharacterizeImageTask 

38 

39__all__ = [ 

40 "claverHeaderToWcs", 

41 "getAverageRaFromHeader", 

42 "getAverageDecFromHeader", 

43 "getAverageAzFromHeader", 

44 "getAverageElFromHeader", 

45 "genericCameraHeaderToWcs", 

46 "getIcrsAtZenith", 

47 "headerToWcs", 

48 "runCharactierizeImage", 

49 "filterSourceCatOnBrightest", 

50] 

51 

52 

53def claverHeaderToWcs( 

54 exp: afwImage.Exposure, nominalRa: float | None = None, nominalDec: float | None = None 

55) -> afwGeom.SkyWcs: 

56 """Given an exposure taken by Chuck Claver at his house, construct a wcs 

57 with the ra/dec set to zenith unless a better guess is supplied. 

58 

59 Automatically sets the platescale depending on the lens. 

60 

61 Parameters 

62 ---------- 

63 exp : `lsst.afw.image.Exposure` 

64 The exposure to construct the wcs for. 

65 nominalRa : `float`, optional 

66 The guess for the ra. 

67 nominalDec : `float`, optional 

68 The guess for the Dec. 

69 

70 Returns 

71 ------- 

72 wcs : `lsst.afw.geom.SkyWcs` 

73 The constructed wcs. 

74 """ 

75 header = exp.getMetadata().toDict() 

76 

77 # set the plate scale depending on the lens and put into CD matrix 

78 # plate scale info from: 

79 # https://confluence.lsstcorp.org/pages/viewpage.action?pageId=191987725 

80 lens = header["INSTLEN"] 

81 if "135mm" in lens: 

82 arcSecPerPix = 8.64 

83 elif "375mm" in lens: 

84 arcSecPerPix = 3.11 

85 elif "750mm" in lens: 

86 arcSecPerPix = 1.56 

87 else: 

88 raise ValueError(f"Unrecognised lens: {lens}") 

89 

90 header["CD1_1"] = arcSecPerPix / 3600 

91 header["CD1_2"] = 0 

92 header["CD2_1"] = 0 

93 header["CD2_2"] = arcSecPerPix / 3600 

94 

95 # calculate the ra/dec at zenith and assume Chuck pointed it vertically 

96 icrs = getIcrsAtZenith( 

97 float(header["OBSLON"]), float(header["OBSLAT"]), float(header["OBSHGT"]), header["UTC"] 

98 ) 

99 header["CRVAL1"] = nominalRa if nominalRa else icrs.ra.degree 

100 header["CRVAL2"] = nominalDec if nominalDec else icrs.dec.degree 

101 

102 # just use the nomimal chip centre, not that it matters 

103 # given radec = zenith 

104 width, height = exp.image.array.shape 

105 header["CRPIX1"] = width / 2 

106 header["CRPIX2"] = height / 2 

107 

108 header["CTYPE1"] = "RA---TAN-SIP" 

109 header["CTYPE2"] = "DEC--TAN-SIP" 

110 

111 wcsPropSet = PropertySet.from_mapping(header) 

112 wcs = SkyWcs(wcsPropSet) 

113 return wcs 

114 

115 

116# don't be tempted to get cute and try to combine these 4 functions. It would 

117# be easy to do but it's not unlikley they will diverge in the future. 

118def getAverageRaFromHeader(header: dict) -> float: 

119 raStart = header.get("RASTART") 

120 raEnd = header.get("RAEND") 

121 if not raStart or not raEnd: 

122 raise RuntimeError(f"Failed to get RA from header due to missing RASTART/END {raStart} {raEnd}") 

123 raStart = float(raStart) 

124 raEnd = float(raEnd) 

125 return (raStart + raEnd) / 2 

126 

127 

128def getAverageDecFromHeader(header: dict) -> float: 

129 decStart = header.get("DECSTART") 

130 decEnd = header.get("DECEND") 

131 if not decStart or not decEnd: 

132 raise RuntimeError(f"Failed to get DEC from header due to missing DECSTART/END {decStart} {decEnd}") 

133 decStart = float(decStart) 

134 decEnd = float(decEnd) 

135 return (decStart + decEnd) / 2 

136 

137 

138def getAverageAzFromHeader(header: dict) -> float: 

139 azStart = header.get("AZSTART") 

140 azEnd = header.get("AZEND") 

141 if not azStart or not azEnd: 

142 raise RuntimeError(f"Failed to get az from header due to missing AZSTART/END {azStart} {azEnd}") 

143 azStart = float(azStart) 

144 azEnd = float(azEnd) 

145 return (azStart + azEnd) / 2 

146 

147 

148def getAverageElFromHeader(header: dict) -> float: 

149 elStart = header.get("ELSTART") 

150 elEnd = header.get("ELEND") 

151 if not elStart or not elEnd: 

152 raise RuntimeError(f"Failed to get el from header due to missing ELSTART/END {elStart} {elEnd}") 

153 elStart = float(elStart) 

154 elEnd = float(elEnd) 

155 return (elStart + elEnd) / 2 

156 

157 

158def patchHeader(header: dict[str, float | int | str]) -> dict[str, float | int | str]: 

159 """This is a TEMPORARY function to patch some info into the headers.""" 

160 if header.get("CAMCODE") == "GC102": # regular aka narrow camera 

161 # the narrow camera currently is wrong about its place scale by of ~2.2 

162 header["SECPIX"] = "1.44" 

163 # update the boresight locations until this goes into the header 

164 # service 

165 header["CRPIX1"] = 1898.10 

166 header["CRPIX2"] = 998.47 

167 if header.get("CAMCODE") == "GC101": # wide camera 

168 # update the boresight locations until this goes into the header 

169 # service 

170 header["CRPIX1"] = 1560.85 

171 header["CRPIX2"] = 1257.15 

172 if header.get("CAMCODE") == "GC103": # fast camera 

173 # use the fast camera chip centre until we know better 

174 header["SECPIX"] = "0.6213" # measured from a fit 

175 header["CRPIX1"] = 329.5 

176 header["CRPIX2"] = 246.5 

177 return header 

178 

179 

180def genericCameraHeaderToWcs(exp: Any) -> afwGeom.SkyWcs: 

181 header = exp.getMetadata().toDict() 

182 header = patchHeader(header) 

183 

184 header["CTYPE1"] = "RA---TAN-SIP" 

185 header["CTYPE2"] = "DEC--TAN-SIP" 

186 

187 header["CRVAL1"] = getAverageRaFromHeader(header) 

188 header["CRVAL2"] = getAverageDecFromHeader(header) 

189 

190 plateScale = header.get("SECPIX") 

191 if not plateScale: 

192 raise RuntimeError("Failed to find platescale in header") 

193 plateScale = float(plateScale) 

194 

195 header["CD1_1"] = plateScale / 3600 

196 header["CD1_2"] = 0 

197 header["CD2_1"] = 0 

198 header["CD2_2"] = plateScale / 3600 

199 

200 wcsPropSet = PropertySet.from_mapping(header) 

201 wcs = SkyWcs(wcsPropSet) 

202 return wcs 

203 

204 

205def getIcrsAtZenith(lon: float, lat: float, height: float, utc: str) -> astropy.coordinates.SkyCoord: 

206 """Get the icrs at zenith given a lat/long/height/time in UTC. 

207 

208 Parameters 

209 ---------- 

210 lon : `float` 

211 The longitude, in degrees. 

212 lat : `float` 

213 The latitude, in degrees. 

214 height : `float` 

215 The height above sea level in meters. 

216 utc : `str` 

217 The time in UTC as an ISO string, e.g. '2022-05-27 20:41:02' 

218 

219 Returns 

220 ------- 

221 skyCoordAtZenith : `astropy.coordinates.SkyCoord` 

222 The skyCoord at zenith. 

223 """ 

224 location = EarthLocation.from_geodetic(lon=lon * u.deg, lat=lat * u.deg, height=height) 

225 obsTime = Time(utc, format="iso", scale="utc") 

226 skyCoord = SkyCoord(AltAz(obstime=obsTime, alt=90.0 * u.deg, az=180.0 * u.deg, location=location)) 

227 return skyCoord.transform_to("icrs") 

228 

229 

230def headerToWcs(header: dict) -> afwGeom.SkyWcs: 

231 """Convert an astrometry.net wcs header dict to a DM wcs object. 

232 

233 Parameters 

234 ---------- 

235 header : `dict` 

236 The wcs header, as returned from from the astrometry_net fit. 

237 

238 Returns 

239 ------- 

240 wcs : `lsst.afw.geom.SkyWcs` 

241 The wcs. 

242 """ 

243 wcsPropSet = PropertySet.from_mapping(header) 

244 return SkyWcs(wcsPropSet) 

245 

246 

247def runCharactierizeImage(exp: afwImage.Exposure, snr: float, minPix: int) -> pipeBase.Struct: 

248 """Run the image characterization task, finding only bright sources. 

249 

250 Parameters 

251 ---------- 

252 exp : `lsst.afw.image.Exposure` 

253 The exposure to characterize. 

254 snr : `float` 

255 The SNR threshold for detection. 

256 minPix : `int` 

257 The minimum number of pixels to count as a source. 

258 

259 Returns 

260 ------- 

261 result : `lsst.pipe.base.Struct` 

262 The result from the image characterization task. 

263 """ 

264 charConfig = CharacterizeImageConfig() 

265 charConfig.doMeasurePsf = False 

266 charConfig.doApCorr = False 

267 charConfig.doDeblend = False 

268 charConfig.doMaskStreaks = False 

269 charConfig.repair.doCosmicRay = False 

270 

271 charConfig.detection.minPixels = minPix 

272 charConfig.detection.thresholdValue = snr 

273 charConfig.detection.includeThresholdMultiplier = 1 

274 charConfig.detection.thresholdType = "stdev" 

275 

276 # fit background with the most simple thing possible as we don't need 

277 # much sophistication here. weighting=False is *required* for very 

278 # large binSizes. 

279 charConfig.background.algorithm = "CONSTANT" 

280 charConfig.background.approxOrderX = 0 

281 charConfig.background.approxOrderY = -1 

282 charConfig.background.binSize = max(exp.getWidth(), exp.getHeight()) 

283 charConfig.background.weighting = False 

284 

285 # set this to use all the same minimal settings as those above 

286 charConfig.detection.background = charConfig.background 

287 

288 charTask = CharacterizeImageTask(config=charConfig) 

289 

290 charResult = charTask.run(exp) 

291 return charResult 

292 

293 

294def filterSourceCatOnBrightest( 

295 catalog: afwTable.SourceCatalog, 

296 brightFraction: float, 

297 minSources: int = 15, 

298 maxSources: int = 200, 

299 flux_field: str = "base_CircularApertureFlux_3_0_instFlux", 

300) -> afwTable.SourceCatalog: 

301 """Filter a sourceCat on the brightness, leaving only the top fraction. 

302 

303 Return a catalog containing the brightest sources in the input. Makes an 

304 initial coarse cut, keeping those above 0.1% of the maximum finite flux, 

305 and then returning the specified fraction of the remaining sources, 

306 or minSources, whichever is greater. 

307 

308 Parameters 

309 ---------- 

310 catalog : `lsst.afw.table.SourceCatalog` 

311 Catalog to be filtered. 

312 brightFraction : `float` 

313 Return this fraction of the brightest sources. 

314 minSources : `int`, optional 

315 Always return at least this many sources. 

316 maxSources : `int`, optional 

317 Never return more than this many sources. 

318 flux_field : `str`, optional 

319 Name of flux field to filter on. 

320 

321 Returns 

322 ------- 

323 result : `lsst.afw.table.SourceCatalog` 

324 Brightest sources in the input image, in ascending order of brightness. 

325 """ 

326 assert minSources > 0 

327 assert brightFraction > 0 and brightFraction <= 1 

328 if not maxSources >= minSources: 

329 raise ValueError( 

330 "maxSources must be greater than or equal to minSources, got " f"{maxSources=}, {minSources=}" 

331 ) 

332 

333 maxFlux = np.nanmax(catalog[flux_field]) 

334 result = catalog.subset(catalog[flux_field] > maxFlux * 0.001) 

335 

336 print(f"Catalog had {len(catalog)} sources, of which {len(result)} were above 0.1% of max") 

337 

338 item = catalog.schema.find(flux_field) 

339 result = catalog.copy(deep=True) # sort() is in place; copy so we don't modify the original 

340 result.sort(item.key) 

341 result = result.copy(deep=True) # make it memory contiguous 

342 end = int(np.ceil(len(result) * brightFraction)) 

343 return result[-min(maxSources, max(end, minSources)) :]