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

136 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-16 02:32 -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 numpy as np 

23 

24import astropy.units as u 

25from astropy.time import Time 

26from astropy.coordinates import AltAz, EarthLocation, SkyCoord 

27 

28from lsst.afw.geom import SkyWcs 

29from lsst.daf.base import PropertySet 

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

31 

32__all__ = [ 

33 'claverHeaderToWcs', 

34 'getAverageRaFromHeader', 

35 'getAverageDecFromHeader', 

36 'getAverageAzFromHeader', 

37 'getAverageElFromHeader', 

38 'genericCameraHeaderToWcs', 

39 'getIcrsAtZenith', 

40 'headerToWcs', 

41 'runCharactierizeImage', 

42 'filterSourceCatOnBrightest', 

43] 

44 

45 

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

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

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

49 

50 Automatically sets the platescale depending on the lens. 

51 

52 Parameters 

53 ---------- 

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

55 The exposure to construct the wcs for. 

56 nominalRa : `float`, optional 

57 The guess for the ra. 

58 nominalDec : `float`, optional 

59 The guess for the Dec. 

60 

61 Returns 

62 ------- 

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

64 The constructed wcs. 

65 """ 

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

67 

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

69 # plate scale info from: 

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

71 lens = header['INSTLEN'] 

72 if '135mm' in lens: 

73 arcSecPerPix = 8.64 

74 elif '375mm' in lens: 

75 arcSecPerPix = 3.11 

76 elif '750mm' in lens: 

77 arcSecPerPix = 1.56 

78 else: 

79 raise ValueError(f'Unrecognised lens: {lens}') 

80 

81 header['CD1_1'] = arcSecPerPix / 3600 

82 header['CD1_2'] = 0 

83 header['CD2_1'] = 0 

84 header['CD2_2'] = arcSecPerPix / 3600 

85 

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

87 icrs = getIcrsAtZenith(float(header['OBSLON']), 

88 float(header['OBSLAT']), 

89 float(header['OBSHGT']), 

90 header['UTC']) 

91 header['CRVAL1'] = nominalRa if nominalRa else icrs.ra.degree 

92 header['CRVAL2'] = nominalDec if nominalDec else icrs.dec.degree 

93 

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

95 # given radec = zenith 

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

97 header['CRPIX1'] = width/2 

98 header['CRPIX2'] = height/2 

99 

100 header['CTYPE1'] = 'RA---TAN-SIP' 

101 header['CTYPE2'] = 'DEC--TAN-SIP' 

102 

103 wcsPropSet = PropertySet.from_mapping(header) 

104 wcs = SkyWcs(wcsPropSet) 

105 return wcs 

106 

107 

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

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

110def getAverageRaFromHeader(header): 

111 raStart = header.get('RASTART') 

112 raEnd = header.get('RAEND') 

113 if not raStart or not raEnd: 

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

115 raStart = float(raStart) 

116 raEnd = float(raEnd) 

117 return (raStart + raEnd) / 2 

118 

119 

120def getAverageDecFromHeader(header): 

121 decStart = header.get('DECSTART') 

122 decEnd = header.get('DECEND') 

123 if not decStart or not decEnd: 

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

125 decStart = float(decStart) 

126 decEnd = float(decEnd) 

127 return (decStart + decEnd) / 2 

128 

129 

130def getAverageAzFromHeader(header): 

131 azStart = header.get('AZSTART') 

132 azEnd = header.get('AZEND') 

133 if not azStart or not azEnd: 

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

135 azStart = float(azStart) 

136 azEnd = float(azEnd) 

137 return (azStart + azEnd) / 2 

138 

139 

140def getAverageElFromHeader(header): 

141 elStart = header.get('ELSTART') 

142 elEnd = header.get('ELEND') 

143 if not elStart or not elEnd: 

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

145 elStart = float(elStart) 

146 elEnd = float(elEnd) 

147 return (elStart + elEnd) / 2 

148 

149 

150def patchHeader(header): 

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

152 """ 

153 if header.get('SECPIX') == '3.11': 

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

155 header['SECPIX'] = '1.44' 

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

157 # service 

158 header['CRPIX1'] = 1898.10 

159 header['CRPIX2'] = 998.47 

160 if header.get('SECPIX') == '8.64': 

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

162 # service 

163 header['CRPIX1'] = 1560.85 

164 header['CRPIX2'] = 1257.15 

165 if header.get('SECPIX') == '0.67': 

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

167 header['SECPIX'] = '0.6213' # measured from a fit 

168 header['CRPIX1'] = 329.5 

169 header['CRPIX2'] = 246.5 

170 return header 

171 

172 

173def genericCameraHeaderToWcs(exp): 

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

175 header = patchHeader(header) 

176 

177 header['CTYPE1'] = 'RA---TAN-SIP' 

178 header['CTYPE2'] = 'DEC--TAN-SIP' 

179 

180 header['CRVAL1'] = getAverageRaFromHeader(header) 

181 header['CRVAL2'] = getAverageDecFromHeader(header) 

182 

183 plateScale = header.get('SECPIX') 

184 if not plateScale: 

185 raise RuntimeError('Failed to find platescale in header') 

186 plateScale = float(plateScale) 

187 

188 header['CD1_1'] = plateScale / 3600 

189 header['CD1_2'] = 0 

190 header['CD2_1'] = 0 

191 header['CD2_2'] = plateScale / 3600 

192 

193 wcsPropSet = PropertySet.from_mapping(header) 

194 wcs = SkyWcs(wcsPropSet) 

195 return wcs 

196 

197 

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

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

200 

201 Parameters 

202 ---------- 

203 lon : `float` 

204 The longitude, in degrees. 

205 lat : `float` 

206 The latitude, in degrees. 

207 height : `float` 

208 The height above sea level in meters. 

209 utc : `str` 

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

211 

212 Returns 

213 ------- 

214 skyCoordAtZenith : `astropy.coordinates.SkyCoord` 

215 The skyCoord at zenith. 

216 """ 

217 location = EarthLocation.from_geodetic(lon=lon*u.deg, 

218 lat=lat*u.deg, 

219 height=height) 

220 obsTime = Time(utc, format='iso', scale='utc') 

221 skyCoord = SkyCoord(AltAz(obstime=obsTime, 

222 alt=90.0*u.deg, 

223 az=180.0*u.deg, 

224 location=location)) 

225 return skyCoord.transform_to('icrs') 

226 

227 

228def headerToWcs(header): 

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

230 

231 Parameters 

232 ---------- 

233 header : `dict` 

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

235 

236 Returns 

237 ------- 

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

239 The wcs. 

240 """ 

241 wcsPropSet = PropertySet.from_mapping(header) 

242 return SkyWcs(wcsPropSet) 

243 

244 

245def runCharactierizeImage(exp, snr, minPix): 

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

247 

248 Parameters 

249 ---------- 

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

251 The exposure to characterize. 

252 snr : `float` 

253 The SNR threshold for detection. 

254 minPix : `int` 

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

256 

257 Returns 

258 ------- 

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

260 The result from the image characterization task. 

261 """ 

262 charConfig = CharacterizeImageConfig() 

263 charConfig.doMeasurePsf = False 

264 charConfig.doApCorr = False 

265 charConfig.doDeblend = False 

266 charConfig.repair.doCosmicRay = False 

267 

268 charConfig.detection.minPixels = minPix 

269 charConfig.detection.thresholdValue = snr 

270 charConfig.detection.includeThresholdMultiplier = 1 

271 

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

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

274 # large binSizes. 

275 charConfig.background.algorithm = 'CONSTANT' 

276 charConfig.background.approxOrderX = 0 

277 charConfig.background.approxOrderY = -1 

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

279 charConfig.background.weighting = False 

280 

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

282 charConfig.detection.background = charConfig.background 

283 

284 charTask = CharacterizeImageTask(config=charConfig) 

285 

286 charResult = charTask.run(exp) 

287 return charResult 

288 

289 

290def filterSourceCatOnBrightest(catalog, brightFraction, minSources=15, maxSources=200, 

291 flux_field="base_CircularApertureFlux_3_0_instFlux"): 

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

293 

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

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

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

297 or minSources, whichever is greater. 

298 

299 Parameters 

300 ---------- 

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

302 Catalog to be filtered. 

303 brightFraction : `float` 

304 Return this fraction of the brightest sources. 

305 minSources : `int`, optional 

306 Always return at least this many sources. 

307 maxSources : `int`, optional 

308 Never return more than this many sources. 

309 flux_field : `str`, optional 

310 Name of flux field to filter on. 

311 

312 Returns 

313 ------- 

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

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

316 """ 

317 assert minSources > 0 

318 assert brightFraction > 0 and brightFraction <= 1 

319 if not maxSources >= minSources: 

320 raise ValueError('maxSources must be greater than or equal to minSources, got ' 

321 f'{maxSources=}, {minSources=}') 

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)):]