Coverage for python/lsst/pipe/tasks/computeExposureSummaryStats.py: 16%

156 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-12-30 01:39 -0800

1# This file is part of pipe_tasks. 

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__all__ = ["ComputeExposureSummaryStatsTask", "ComputeExposureSummaryStatsConfig"] 

23 

24import warnings 

25import numpy as np 

26from scipy.stats import median_abs_deviation as sigmaMad 

27import astropy.units as units 

28from astropy.time import Time 

29from astropy.coordinates import AltAz, SkyCoord, EarthLocation 

30from lsst.daf.base import DateTime 

31 

32import lsst.pipe.base as pipeBase 

33import lsst.pex.config as pexConfig 

34import lsst.afw.math as afwMath 

35import lsst.afw.image as afwImage 

36import lsst.geom 

37from lsst.utils.timer import timeMethod 

38 

39 

40class ComputeExposureSummaryStatsConfig(pexConfig.Config): 

41 """Config for ComputeExposureSummaryTask""" 

42 sigmaClip = pexConfig.Field( 

43 dtype=float, 

44 doc="Sigma for outlier rejection for sky noise.", 

45 default=3.0, 

46 ) 

47 clipIter = pexConfig.Field( 

48 dtype=int, 

49 doc="Number of iterations of outlier rejection for sky noise.", 

50 default=2, 

51 ) 

52 badMaskPlanes = pexConfig.ListField( 

53 dtype=str, 

54 doc="Mask planes that, if set, the associated pixel should not be included sky noise calculation.", 

55 default=("NO_DATA", "SUSPECT"), 

56 ) 

57 starSelection = pexConfig.Field( 

58 doc="Field to select sources to be used in the PSF statistics computation.", 

59 dtype=str, 

60 default="calib_psf_used" 

61 ) 

62 starShape = pexConfig.Field( 

63 doc="Base name of columns to use for the source shape in the PSF statistics computation.", 

64 dtype=str, 

65 default="base_SdssShape" 

66 ) 

67 psfShape = pexConfig.Field( 

68 doc="Base name of columns to use for the PSF shape in the PSF statistics computation.", 

69 dtype=str, 

70 default="base_SdssShape_psf" 

71 ) 

72 

73 

74class ComputeExposureSummaryStatsTask(pipeBase.Task): 

75 """Task to compute exposure summary statistics. 

76 

77 This task computes various quantities suitable for DPDD and other 

78 downstream processing at the detector centers, including: 

79 - psfSigma 

80 - psfArea 

81 - psfIxx 

82 - psfIyy 

83 - psfIxy 

84 - ra 

85 - decl 

86 - zenithDistance 

87 - zeroPoint 

88 - skyBg 

89 - skyNoise 

90 - meanVar 

91 - raCorners 

92 - decCorners 

93 - astromOffsetMean 

94 - astromOffsetStd 

95 

96 These additional quantities are computed from the stars in the detector: 

97 - psfStarDeltaE1Median 

98 - psfStarDeltaE2Median 

99 - psfStarDeltaE1Scatter 

100 - psfStarDeltaE2Scatter 

101 - psfStarDeltaSizeMedian 

102 - psfStarDeltaSizeScatter 

103 - psfStarScaledDeltaSizeScatter 

104 """ 

105 ConfigClass = ComputeExposureSummaryStatsConfig 

106 _DefaultName = "computeExposureSummaryStats" 

107 

108 @timeMethod 

109 def run(self, exposure, sources, background): 

110 """Measure exposure statistics from the exposure, sources, and background. 

111 

112 Parameters 

113 ---------- 

114 exposure : `lsst.afw.image.ExposureF` 

115 sources : `lsst.afw.table.SourceCatalog` 

116 background : `lsst.afw.math.BackgroundList` 

117 

118 Returns 

119 ------- 

120 summary : `lsst.afw.image.ExposureSummary` 

121 """ 

122 self.log.info("Measuring exposure statistics") 

123 

124 summary = afwImage.ExposureSummaryStats() 

125 

126 bbox = exposure.getBBox() 

127 

128 psf = exposure.getPsf() 

129 self.update_psf_stats(summary, psf, bbox, sources, mask=exposure.mask) 

130 

131 wcs = exposure.getWcs() 

132 visitInfo = exposure.getInfo().getVisitInfo() 

133 self.update_wcs_stats(summary, wcs, bbox, visitInfo) 

134 

135 photoCalib = exposure.getPhotoCalib() 

136 self.update_photo_calib_stats(summary, photoCalib) 

137 

138 self.update_background_stats(summary, background) 

139 

140 self.update_masked_image_stats(summary, exposure.getMaskedImage()) 

141 

142 md = exposure.getMetadata() 

143 if 'SFM_ASTROM_OFFSET_MEAN' in md: 

144 summary.astromOffsetMean = md['SFM_ASTROM_OFFSET_MEAN'] 

145 summary.astromOffsetStd = md['SFM_ASTROM_OFFSET_STD'] 

146 

147 return summary 

148 

149 def update_psf_stats(self, summary, psf, bbox, sources=None, mask=None, sources_columns=None): 

150 """Compute all summary-statistic fields that depend on the PSF model. 

151 

152 Parameters 

153 ---------- 

154 summary : `lsst.afw.image.ExposureSummaryStats` 

155 Summary object to update in-place. 

156 psf : `lsst.afw.detection.Psf` or `None` 

157 Point spread function model. If `None`, all fields that depend on 

158 the PSF will be reset (generally to NaN). 

159 bbox : `lsst.geom.Box2I` 

160 Bounding box of the image for which summary stats are being 

161 computed. 

162 sources : `lsst.afw.table.SourceCatalog`, optional 

163 Catalog for quantities that are computed from source table columns. 

164 If `None`, these quantities will be reset (generally to NaN). 

165 mask : `lsst.afw.image.Mask`, optional 

166 Mask image that may be used to compute distance-to-nearest-star 

167 metrics. 

168 sources_columns : `collections.abc.Set` [ `str` ], optional 

169 Set of all column names in ``sources``. If provided, ``sources`` 

170 may be any table type for which string indexes yield column arrays. 

171 If not provided, ``sources`` is assumed to be an 

172 `lsst.afw.table.SourceCatalog`. 

173 """ 

174 nan = float("nan") 

175 summary.psfSigma = nan 

176 summary.psfIxx = nan 

177 summary.psfIyy = nan 

178 summary.psfIxy = nan 

179 summary.psfArea = nan 

180 summary.nPsfStar = 0 

181 summary.psfStarDeltaE1Median = nan 

182 summary.psfStarDeltaE2Median = nan 

183 summary.psfStarDeltaE1Scatter = nan 

184 summary.psfStarDeltaE2Scatter = nan 

185 summary.psfStarDeltaSizeMedian = nan 

186 summary.psfStarDeltaSizeScatter = nan 

187 summary.psfStarScaledDeltaSizeScatter = nan 

188 

189 if psf is None: 

190 return 

191 shape = psf.computeShape(bbox.getCenter()) 

192 summary.psfSigma = shape.getDeterminantRadius() 

193 summary.psfIxx = shape.getIxx() 

194 summary.psfIyy = shape.getIyy() 

195 summary.psfIxy = shape.getIxy() 

196 im = psf.computeKernelImage(bbox.getCenter()) 

197 # The calculation of effective psf area is taken from 

198 # meas_base/src/PsfFlux.cc#L112. See 

199 # https://github.com/lsst/meas_base/blob/ 

200 # 750bffe6620e565bda731add1509507f5c40c8bb/src/PsfFlux.cc#L112 

201 summary.psfArea = float(np.sum(im.array)/np.sum(im.array**2.)) 

202 

203 if sources is None: 

204 # No sources are available (as in some tests) 

205 return 

206 

207 if sources_columns is None: 

208 sources_columns = sources.schema.getNames() 

209 if ( 

210 self.config.starSelection not in sources_columns 

211 or self.config.starShape + '_flag' not in sources_columns 

212 ): 

213 # The source catalog does not have the necessary fields (as in some tests) 

214 return 

215 

216 mask = sources[self.config.starSelection] & (~sources[self.config.starShape + '_flag']) 

217 nPsfStar = mask.sum() 

218 

219 if nPsfStar == 0: 

220 # No stars to measure statistics, so we must return the defaults 

221 # of 0 stars and NaN values. 

222 return 

223 

224 starXX = sources[self.config.starShape + '_xx'][mask] 

225 starYY = sources[self.config.starShape + '_yy'][mask] 

226 starXY = sources[self.config.starShape + '_xy'][mask] 

227 psfXX = sources[self.config.psfShape + '_xx'][mask] 

228 psfYY = sources[self.config.psfShape + '_yy'][mask] 

229 psfXY = sources[self.config.psfShape + '_xy'][mask] 

230 

231 starSize = (starXX*starYY - starXY**2.)**0.25 

232 starE1 = (starXX - starYY)/(starXX + starYY) 

233 starE2 = 2*starXY/(starXX + starYY) 

234 starSizeMedian = np.median(starSize) 

235 

236 psfSize = (psfXX*psfYY - psfXY**2)**0.25 

237 psfE1 = (psfXX - psfYY)/(psfXX + psfYY) 

238 psfE2 = 2*psfXY/(psfXX + psfYY) 

239 

240 psfStarDeltaE1Median = np.median(starE1 - psfE1) 

241 psfStarDeltaE1Scatter = sigmaMad(starE1 - psfE1, scale='normal') 

242 psfStarDeltaE2Median = np.median(starE2 - psfE2) 

243 psfStarDeltaE2Scatter = sigmaMad(starE2 - psfE2, scale='normal') 

244 

245 psfStarDeltaSizeMedian = np.median(starSize - psfSize) 

246 psfStarDeltaSizeScatter = sigmaMad(starSize - psfSize, scale='normal') 

247 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian**2. 

248 

249 summary.nPsfStar = int(nPsfStar) 

250 summary.psfStarDeltaE1Median = float(psfStarDeltaE1Median) 

251 summary.psfStarDeltaE2Median = float(psfStarDeltaE2Median) 

252 summary.psfStarDeltaE1Scatter = float(psfStarDeltaE1Scatter) 

253 summary.psfStarDeltaE2Scatter = float(psfStarDeltaE2Scatter) 

254 summary.psfStarDeltaSizeMedian = float(psfStarDeltaSizeMedian) 

255 summary.psfStarDeltaSizeScatter = float(psfStarDeltaSizeScatter) 

256 summary.psfStarScaledDeltaSizeScatter = float(psfStarScaledDeltaSizeScatter) 

257 

258 def update_wcs_stats(self, summary, wcs, bbox, visitInfo): 

259 """Compute all summary-statistic fields that depend on the WCS model. 

260 

261 Parameters 

262 ---------- 

263 summary : `lsst.afw.image.ExposureSummaryStats` 

264 Summary object to update in-place. 

265 wcs : `lsst.afw.geom.SkyWcs` or `None` 

266 Astrometric calibration model. If `None`, all fields that depend 

267 on the WCS will be reset (generally to NaN). 

268 bbox : `lsst.geom.Box2I` 

269 Bounding box of the image for which summary stats are being 

270 computed. 

271 visitInfo : `lsst.afw.image.VisitInfo` 

272 Observation information used in together with ``wcs`` to compute 

273 the zenith distance. 

274 """ 

275 nan = float("nan") 

276 summary.raCorners = [nan]*4 

277 summary.decCorners = [nan]*4 

278 summary.ra = nan 

279 summary.decl = nan 

280 summary.zenithDistance = nan 

281 

282 if wcs is None: 

283 return 

284 

285 sph_pts = wcs.pixelToSky(lsst.geom.Box2D(bbox).getCorners()) 

286 summary.raCorners = [float(sph.getRa().asDegrees()) for sph in sph_pts] 

287 summary.decCorners = [float(sph.getDec().asDegrees()) for sph in sph_pts] 

288 

289 sph_pt = wcs.pixelToSky(bbox.getCenter()) 

290 summary.ra = sph_pt.getRa().asDegrees() 

291 summary.decl = sph_pt.getDec().asDegrees() 

292 

293 date = visitInfo.getDate() 

294 

295 if date.isValid(): 

296 # We compute the zenithDistance at the center of the detector rather 

297 # than use the boresight value available via the visitInfo, because 

298 # the zenithDistance may vary significantly over a large field of view. 

299 observatory = visitInfo.getObservatory() 

300 loc = EarthLocation(lat=observatory.getLatitude().asDegrees()*units.deg, 

301 lon=observatory.getLongitude().asDegrees()*units.deg, 

302 height=observatory.getElevation()*units.m) 

303 obstime = Time(visitInfo.getDate().get(system=DateTime.MJD), 

304 location=loc, format='mjd') 

305 coord = SkyCoord( 

306 summary.ra*units.degree, 

307 summary.decl*units.degree, 

308 obstime=obstime, 

309 location=loc, 

310 ) 

311 with warnings.catch_warnings(): 

312 warnings.simplefilter('ignore') 

313 altaz = coord.transform_to(AltAz) 

314 

315 summary.zenithDistance = float(90.0 - altaz.alt.degree) 

316 

317 def update_photo_calib_stats(self, summary, photo_calib): 

318 """Compute all summary-statistic fields that depend on the photometric 

319 calibration model. 

320 

321 Parameters 

322 ---------- 

323 summary : `lsst.afw.image.ExposureSummaryStats` 

324 Summary object to update in-place. 

325 photo_calib : `lsst.afw.image.PhotoCalib` or `None` 

326 Photometric calibration model. If `None`, all fields that depend 

327 on the photometric calibration will be reset (generally to NaN). 

328 """ 

329 if photo_calib is not None: 

330 summary.zeroPoint = float(2.5*np.log10(photo_calib.getInstFluxAtZeroMagnitude())) 

331 else: 

332 summary.zeroPoint = float("nan") 

333 

334 def update_background_stats(self, summary, background): 

335 """Compute summary-statistic fields that depend only on the 

336 background model. 

337 

338 Parameters 

339 ---------- 

340 summary : `lsst.afw.image.ExposureSummaryStats` 

341 Summary object to update in-place. 

342 background : `lsst.afw.math.BackgroundList` or `None` 

343 Background model. If `None`, all fields that depend on the 

344 background will be reset (generally to NaN). 

345 

346 Notes 

347 ----- 

348 This does not include fields that depend on the background-subtracted 

349 masked image; when the background changes, it should generally be 

350 applied to the image and `update_masked_image_stats` should be called 

351 as well. 

352 """ 

353 if background is not None: 

354 bgStats = (bg[0].getStatsImage().getImage().array 

355 for bg in background) 

356 summary.skyBg = float(sum(np.median(bg[np.isfinite(bg)]) for bg in bgStats)) 

357 else: 

358 summary.skyBg = float("nan") 

359 

360 def update_masked_image_stats(self, summary, masked_image): 

361 """Compute summary-statistic fields that depend on the masked image 

362 itself. 

363 

364 Parameters 

365 ---------- 

366 summary : `lsst.afw.image.ExposureSummaryStats` 

367 Summary object to update in-place. 

368 masked_image : `lsst.afw.image.MaskedImage` or `None` 

369 Masked image. If `None`, all fields that depend 

370 on the masked image will be reset (generally to NaN). 

371 """ 

372 nan = float("nan") 

373 if masked_image is None: 

374 summary.skyNoise = nan 

375 summary.meanVar = nan 

376 return 

377 statsCtrl = afwMath.StatisticsControl() 

378 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

379 statsCtrl.setNumIter(self.config.clipIter) 

380 statsCtrl.setAndMask(afwImage.Mask.getPlaneBitMask(self.config.badMaskPlanes)) 

381 statsCtrl.setNanSafe(True) 

382 

383 statObj = afwMath.makeStatistics(masked_image, afwMath.STDEVCLIP, statsCtrl) 

384 skyNoise, _ = statObj.getResult(afwMath.STDEVCLIP) 

385 summary.skyNoise = skyNoise 

386 

387 statObj = afwMath.makeStatistics(masked_image.variance, masked_image.mask, afwMath.MEANCLIP, 

388 statsCtrl) 

389 meanVar, _ = statObj.getResult(afwMath.MEANCLIP) 

390 summary.meanVar = meanVar