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

138 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-25 12:24 -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 

22import astropy.units as u 

23import numpy as np 

24from astropy.coordinates import AltAz, EarthLocation, SkyCoord 

25from astropy.time import Time 

26 

27from lsst.afw.geom import SkyWcs 

28from lsst.daf.base import PropertySet 

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

30 

31__all__ = [ 

32 "claverHeaderToWcs", 

33 "getAverageRaFromHeader", 

34 "getAverageDecFromHeader", 

35 "getAverageAzFromHeader", 

36 "getAverageElFromHeader", 

37 "genericCameraHeaderToWcs", 

38 "getIcrsAtZenith", 

39 "headerToWcs", 

40 "runCharactierizeImage", 

41 "filterSourceCatOnBrightest", 

42] 

43 

44 

45def claverHeaderToWcs(exp, nominalRa=None, nominalDec=None): 

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

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

48 

49 Automatically sets the platescale depending on the lens. 

50 

51 Parameters 

52 ---------- 

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

54 The exposure to construct the wcs for. 

55 nominalRa : `float`, optional 

56 The guess for the ra. 

57 nominalDec : `float`, optional 

58 The guess for the Dec. 

59 

60 Returns 

61 ------- 

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

63 The constructed wcs. 

64 """ 

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

66 

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

68 # plate scale info from: 

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

70 lens = header["INSTLEN"] 

71 if "135mm" in lens: 

72 arcSecPerPix = 8.64 

73 elif "375mm" in lens: 

74 arcSecPerPix = 3.11 

75 elif "750mm" in lens: 

76 arcSecPerPix = 1.56 

77 else: 

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

79 

80 header["CD1_1"] = arcSecPerPix / 3600 

81 header["CD1_2"] = 0 

82 header["CD2_1"] = 0 

83 header["CD2_2"] = arcSecPerPix / 3600 

84 

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

86 icrs = getIcrsAtZenith( 

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

88 ) 

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

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

91 

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

93 # given radec = zenith 

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

95 header["CRPIX1"] = width / 2 

96 header["CRPIX2"] = height / 2 

97 

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

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

100 

101 wcsPropSet = PropertySet.from_mapping(header) 

102 wcs = SkyWcs(wcsPropSet) 

103 return wcs 

104 

105 

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

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

108def getAverageRaFromHeader(header): 

109 raStart = header.get("RASTART") 

110 raEnd = header.get("RAEND") 

111 if not raStart or not raEnd: 

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

113 raStart = float(raStart) 

114 raEnd = float(raEnd) 

115 return (raStart + raEnd) / 2 

116 

117 

118def getAverageDecFromHeader(header): 

119 decStart = header.get("DECSTART") 

120 decEnd = header.get("DECEND") 

121 if not decStart or not decEnd: 

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

123 decStart = float(decStart) 

124 decEnd = float(decEnd) 

125 return (decStart + decEnd) / 2 

126 

127 

128def getAverageAzFromHeader(header): 

129 azStart = header.get("AZSTART") 

130 azEnd = header.get("AZEND") 

131 if not azStart or not azEnd: 

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

133 azStart = float(azStart) 

134 azEnd = float(azEnd) 

135 return (azStart + azEnd) / 2 

136 

137 

138def getAverageElFromHeader(header): 

139 elStart = header.get("ELSTART") 

140 elEnd = header.get("ELEND") 

141 if not elStart or not elEnd: 

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

143 elStart = float(elStart) 

144 elEnd = float(elEnd) 

145 return (elStart + elEnd) / 2 

146 

147 

148def patchHeader(header): 

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

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

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

152 header["SECPIX"] = "1.44" 

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

154 # service 

155 header["CRPIX1"] = 1898.10 

156 header["CRPIX2"] = 998.47 

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

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

159 # service 

160 header["CRPIX1"] = 1560.85 

161 header["CRPIX2"] = 1257.15 

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

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

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

165 header["CRPIX1"] = 329.5 

166 header["CRPIX2"] = 246.5 

167 return header 

168 

169 

170def genericCameraHeaderToWcs(exp): 

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

172 header = patchHeader(header) 

173 

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

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

176 

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

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

179 

180 plateScale = header.get("SECPIX") 

181 if not plateScale: 

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

183 plateScale = float(plateScale) 

184 

185 header["CD1_1"] = plateScale / 3600 

186 header["CD1_2"] = 0 

187 header["CD2_1"] = 0 

188 header["CD2_2"] = plateScale / 3600 

189 

190 wcsPropSet = PropertySet.from_mapping(header) 

191 wcs = SkyWcs(wcsPropSet) 

192 return wcs 

193 

194 

195def getIcrsAtZenith(lon, lat, height, utc): 

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

197 

198 Parameters 

199 ---------- 

200 lon : `float` 

201 The longitude, in degrees. 

202 lat : `float` 

203 The latitude, in degrees. 

204 height : `float` 

205 The height above sea level in meters. 

206 utc : `str` 

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

208 

209 Returns 

210 ------- 

211 skyCoordAtZenith : `astropy.coordinates.SkyCoord` 

212 The skyCoord at zenith. 

213 """ 

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

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

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

217 return skyCoord.transform_to("icrs") 

218 

219 

220def headerToWcs(header): 

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

222 

223 Parameters 

224 ---------- 

225 header : `dict` 

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

227 

228 Returns 

229 ------- 

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

231 The wcs. 

232 """ 

233 wcsPropSet = PropertySet.from_mapping(header) 

234 return SkyWcs(wcsPropSet) 

235 

236 

237def runCharactierizeImage(exp, snr, minPix): 

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

239 

240 Parameters 

241 ---------- 

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

243 The exposure to characterize. 

244 snr : `float` 

245 The SNR threshold for detection. 

246 minPix : `int` 

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

248 

249 Returns 

250 ------- 

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

252 The result from the image characterization task. 

253 """ 

254 charConfig = CharacterizeImageConfig() 

255 charConfig.doMeasurePsf = False 

256 charConfig.doApCorr = False 

257 charConfig.doDeblend = False 

258 charConfig.doMaskStreaks = False 

259 charConfig.repair.doCosmicRay = False 

260 

261 charConfig.detection.minPixels = minPix 

262 charConfig.detection.thresholdValue = snr 

263 charConfig.detection.includeThresholdMultiplier = 1 

264 charConfig.detection.thresholdType = "stdev" 

265 

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

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

268 # large binSizes. 

269 charConfig.background.algorithm = "CONSTANT" 

270 charConfig.background.approxOrderX = 0 

271 charConfig.background.approxOrderY = -1 

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

273 charConfig.background.weighting = False 

274 

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

276 charConfig.detection.background = charConfig.background 

277 

278 charTask = CharacterizeImageTask(config=charConfig) 

279 

280 charResult = charTask.run(exp) 

281 return charResult 

282 

283 

284def filterSourceCatOnBrightest( 

285 catalog, 

286 brightFraction, 

287 minSources=15, 

288 maxSources=200, 

289 flux_field="base_CircularApertureFlux_3_0_instFlux", 

290): 

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

292 

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

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

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

296 or minSources, whichever is greater. 

297 

298 Parameters 

299 ---------- 

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

301 Catalog to be filtered. 

302 brightFraction : `float` 

303 Return this fraction of the brightest sources. 

304 minSources : `int`, optional 

305 Always return at least this many sources. 

306 maxSources : `int`, optional 

307 Never return more than this many sources. 

308 flux_field : `str`, optional 

309 Name of flux field to filter on. 

310 

311 Returns 

312 ------- 

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

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

315 """ 

316 assert minSources > 0 

317 assert brightFraction > 0 and brightFraction <= 1 

318 if not maxSources >= minSources: 

319 raise ValueError( 

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

321 ) 

322 

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

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

325 

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

327 

328 item = catalog.schema.find(flux_field) 

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

330 result.sort(item.key) 

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

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

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