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

376 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-22 09:17 +0000

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 as geom 

37from lsst.meas.algorithms import ScienceSourceSelectorTask 

38from lsst.utils.timer import timeMethod 

39import lsst.ip.isr as ipIsr 

40 

41 

42class ComputeExposureSummaryStatsConfig(pexConfig.Config): 

43 """Config for ComputeExposureSummaryTask""" 

44 sigmaClip = pexConfig.Field( 

45 dtype=float, 

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

47 default=3.0, 

48 ) 

49 clipIter = pexConfig.Field( 

50 dtype=int, 

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

52 default=2, 

53 ) 

54 badMaskPlanes = pexConfig.ListField( 

55 dtype=str, 

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

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

58 ) 

59 starSelection = pexConfig.Field( 

60 doc="Field to select full list of sources used for PSF modeling.", 

61 dtype=str, 

62 default="calib_psf_used", 

63 ) 

64 starSelector = pexConfig.ConfigurableField( 

65 target=ScienceSourceSelectorTask, 

66 doc="Selection of sources to compute PSF star statistics.", 

67 ) 

68 starShape = pexConfig.Field( 

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

70 dtype=str, 

71 default="slot_Shape" 

72 ) 

73 psfShape = pexConfig.Field( 

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

75 dtype=str, 

76 default="slot_PsfShape" 

77 ) 

78 psfSampling = pexConfig.Field( 

79 dtype=int, 

80 doc="Sampling rate in pixels in each dimension for the maxDistToNearestPsf metric " 

81 "caclulation grid (the tradeoff is between adequate sampling versus speed).", 

82 default=8, 

83 ) 

84 psfGridSampling = pexConfig.Field( 

85 dtype=int, 

86 doc="Sampling rate in pixels in each dimension for PSF model robustness metric " 

87 "caclulations grid (the tradeoff is between adequate sampling versus speed).", 

88 default=96, 

89 ) 

90 minPsfApRadiusPix = pexConfig.Field( 

91 dtype=float, 

92 doc="Minimum radius in pixels of the aperture within which to measure the flux of " 

93 "the PSF model for the psfApFluxDelta metric calculation (the radius is computed as " 

94 "max(``minPsfApRadius``, 3*psfSigma)).", 

95 default=2.0, 

96 ) 

97 psfApCorrFieldName = pexConfig.Field( 

98 doc="Name of the flux column associated with the aperture correction of the PSF model " 

99 "to use for the psfApCorrSigmaScaledDelta metric calculation.", 

100 dtype=str, 

101 default="base_PsfFlux_instFlux" 

102 ) 

103 psfBadMaskPlanes = pexConfig.ListField( 

104 dtype=str, 

105 doc="Mask planes that, if set, the associated pixel should not be included in the PSF model " 

106 "robutsness metric calculations (namely, maxDistToNearestPsf and psfTraceRadiusDelta).", 

107 default=("BAD", "CR", "EDGE", "INTRP", "NO_DATA", "SAT", "SUSPECT"), 

108 ) 

109 fiducialSkyBackground = pexConfig.DictField( 

110 keytype=str, 

111 itemtype=float, 

112 doc="Fiducial sky background level (ADU/s) assumed when calculating effective exposure time. " 

113 "Keyed by band.", 

114 default={'u': 1.0, 'g': 1.0, 'r': 1.0, 'i': 1.0, 'z': 1.0, 'y': 1.0}, 

115 ) 

116 fiducialPsfSigma = pexConfig.DictField( 

117 keytype=str, 

118 itemtype=float, 

119 doc="Fiducial PSF sigma (pixels) assumed when calculating effective exposure time. " 

120 "Keyed by band.", 

121 default={'u': 1.0, 'g': 1.0, 'r': 1.0, 'i': 1.0, 'z': 1.0, 'y': 1.0}, 

122 ) 

123 fiducialZeroPoint = pexConfig.DictField( 

124 keytype=str, 

125 itemtype=float, 

126 doc="Fiducial zero point assumed when calculating effective exposure time. " 

127 "Keyed by band.", 

128 default={'u': 25.0, 'g': 25.0, 'r': 25.0, 'i': 25.0, 'z': 25.0, 'y': 25.0}, 

129 ) 

130 fiducialReadNoise = pexConfig.DictField( 

131 keytype=str, 

132 itemtype=float, 

133 doc="Fiducial readnoise (electrons) assumed when calculating effective exposure time. " 

134 "Keyed by band.", 

135 default={'u': 9.0, 'g': 9.0, 'r': 9.0, 'i': 9.0, 'z': 9.0, 'y': 9.0}, 

136 ) 

137 fiducialExpTime = pexConfig.DictField( 

138 keytype=str, 

139 itemtype=float, 

140 doc="Fiducial exposure time (seconds). " 

141 "Keyed by band.", 

142 default={'u': 30.0, 'g': 30.0, 'r': 30.0, 'i': 30.0, 'z': 30.0, 'y': 30.0}, 

143 ) 

144 fiducialMagLim = pexConfig.DictField( 

145 keytype=str, 

146 itemtype=float, 

147 doc="Fiducial magnitude limit depth at SNR=5. " 

148 "Keyed by band.", 

149 default={'u': 25.0, 'g': 25.0, 'r': 25.0, 'i': 25.0, 'z': 25.0, 'y': 25.0}, 

150 ) 

151 maxEffectiveTransparency = pexConfig.Field( 

152 dtype=float, 

153 doc="Maximum value allowed for effective transparency scale factor (often inf or 1.0).", 

154 default=float('inf') 

155 ) 

156 magLimSnr = pexConfig.Field( 

157 dtype=float, 

158 doc="Signal-to-noise ratio for computing the magnitude limit depth.", 

159 default=5.0 

160 ) 

161 

162 def setDefaults(self): 

163 super().setDefaults() 

164 

165 self.starSelector.setDefaults() 

166 self.starSelector.doFlags = True 

167 self.starSelector.doSignalToNoise = True 

168 self.starSelector.doUnresolved = False 

169 self.starSelector.doIsolated = False 

170 self.starSelector.doRequireFiniteRaDec = False 

171 self.starSelector.doRequirePrimary = False 

172 

173 self.starSelector.signalToNoise.minimum = 50.0 

174 self.starSelector.signalToNoise.maximum = 1000.0 

175 

176 self.starSelector.flags.bad = ["slot_Shape_flag", "slot_PsfFlux_flag"] 

177 # Select stars used for PSF modeling. 

178 self.starSelector.flags.good = ["calib_psf_used"] 

179 

180 self.starSelector.signalToNoise.fluxField = "slot_PsfFlux_instFlux" 

181 self.starSelector.signalToNoise.errField = "slot_PsfFlux_instFluxErr" 

182 

183 def fiducialMagnitudeLimit(self, band, pixelScale, gain): 

184 """Compute the fiducial point-source magnitude limit based on config values. 

185 This follows the conventions laid out in SMTN-002, LSE-40, and DMTN-296. 

186 

187 Parameters 

188 ---------- 

189 band : `str` 

190 The band of interest 

191 pixelScale : `float` 

192 The pixel scale [arcsec/pix] 

193 gain : `float` 

194 The instrumental gain for the exposure [e-/ADU]. The gain should 

195 be 1.0 if the image units are [e-]. 

196 

197 Returns 

198 ------- 

199 magnitude_limit : `float` 

200 The fiducial magnitude limit calculated from fiducial values. 

201 """ 

202 nan = float("nan") 

203 

204 # Fiducials from config 

205 fiducialPsfSigma = self.fiducialPsfSigma.get(band, nan) 

206 fiducialSkyBackground = self.fiducialSkyBackground.get(band, nan) 

207 fiducialZeroPoint = self.fiducialZeroPoint.get(band, nan) 

208 fiducialReadNoise = self.fiducialReadNoise.get(band, nan) 

209 fiducialExpTime = self.fiducialExpTime.get(band, nan) 

210 magLimSnr = self.magLimSnr 

211 

212 # Derived fiducial quantities 

213 fiducialPsfArea = psf_sigma_to_psf_area(fiducialPsfSigma, pixelScale) 

214 fiducialZeroPoint += 2.5*np.log10(fiducialExpTime) 

215 fiducialSkyBg = fiducialSkyBackground * fiducialExpTime 

216 fiducialReadNoise /= gain 

217 

218 # Calculate the fiducial magnitude limit 

219 fiducialMagLim = compute_magnitude_limit(fiducialPsfArea, 

220 fiducialSkyBg, 

221 fiducialZeroPoint, 

222 fiducialReadNoise, 

223 gain, 

224 magLimSnr) 

225 

226 return fiducialMagLim 

227 

228 

229class ComputeExposureSummaryStatsTask(pipeBase.Task): 

230 """Task to compute exposure summary statistics. 

231 

232 This task computes various quantities suitable for DPDD and other 

233 downstream processing at the detector centers, including: 

234 - expTime 

235 - psfSigma 

236 - psfArea 

237 - psfIxx 

238 - psfIyy 

239 - psfIxy 

240 - ra 

241 - dec 

242 - pixelScale (arcsec/pixel) 

243 - zenithDistance 

244 - zeroPoint 

245 - skyBg 

246 - skyNoise 

247 - meanVar 

248 - raCorners 

249 - decCorners 

250 - astromOffsetMean 

251 - astromOffsetStd 

252 

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

254 - psfStarDeltaE1Median 

255 - psfStarDeltaE2Median 

256 - psfStarDeltaE1Scatter 

257 - psfStarDeltaE2Scatter 

258 - psfStarDeltaSizeMedian 

259 - psfStarDeltaSizeScatter 

260 - psfStarScaledDeltaSizeScatter 

261 

262 These quantities are computed based on the PSF model and image mask 

263 to assess the robustness of the PSF model across a given detector 

264 (against, e.g., extrapolation instability): 

265 - maxDistToNearestPsf 

266 - psfTraceRadiusDelta 

267 - psfApFluxDelta 

268 

269 This quantity is computed based on the aperture correction map, the 

270 psfSigma, and the image mask to assess the robustness of the aperture 

271 corrections across a given detector: 

272 - psfApCorrSigmaScaledDelta 

273 

274 These quantities are computed based on the shape measurements of the 

275 sources used in the PSF model to assess the image quality (i.e. as a 

276 measure of the deviation from a circular shape): 

277 - starEMedian 

278 - starUnNormalizedEMedian 

279 

280 These quantities are computed to assess depth: 

281 - effTime 

282 - effTimePsfSigmaScale 

283 - effTimeSkyBgScale 

284 - effTimeZeroPointScale 

285 - magLim 

286 """ 

287 ConfigClass = ComputeExposureSummaryStatsConfig 

288 _DefaultName = "computeExposureSummaryStats" 

289 

290 def __init__(self, **kwargs): 

291 super().__init__(**kwargs) 

292 

293 self.makeSubtask("starSelector") 

294 

295 @timeMethod 

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

297 """Measure exposure statistics from the exposure, sources, and 

298 background. 

299 

300 Parameters 

301 ---------- 

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

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

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

305 

306 Returns 

307 ------- 

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

309 """ 

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

311 

312 summary = afwImage.ExposureSummaryStats() 

313 

314 # Set exposure time. 

315 exposureTime = exposure.getInfo().getVisitInfo().getExposureTime() 

316 summary.expTime = exposureTime 

317 

318 bbox = exposure.getBBox() 

319 

320 psf = exposure.getPsf() 

321 self.update_psf_stats( 

322 summary, psf, bbox, sources, image_mask=exposure.mask, image_ap_corr_map=exposure.apCorrMap 

323 ) 

324 

325 wcs = exposure.getWcs() 

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

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

328 

329 photoCalib = exposure.getPhotoCalib() 

330 self.update_photo_calib_stats(summary, photoCalib) 

331 

332 self.update_background_stats(summary, background) 

333 

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

335 

336 self.update_magnitude_limit_stats(summary, exposure) 

337 

338 self.update_effective_time_stats(summary, exposure) 

339 

340 md = exposure.getMetadata() 

341 if 'SFM_ASTROM_OFFSET_MEAN' in md: 

342 summary.astromOffsetMean = md['SFM_ASTROM_OFFSET_MEAN'] 

343 summary.astromOffsetStd = md['SFM_ASTROM_OFFSET_STD'] 

344 

345 return summary 

346 

347 def update_psf_stats( 

348 self, 

349 summary, 

350 psf, 

351 bbox, 

352 sources=None, 

353 image_mask=None, 

354 image_ap_corr_map=None, 

355 sources_is_astropy=False, 

356 ): 

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

358 

359 Parameters 

360 ---------- 

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

362 Summary object to update in-place. 

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

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

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

366 bbox : `lsst.geom.Box2I` 

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

368 computed. 

369 sources : `lsst.afw.table.SourceCatalog` or `astropy.table.Table` 

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

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

372 The type of this table must correspond to the 

373 ``sources_is_astropy`` argument. 

374 image_mask : `lsst.afw.image.Mask`, optional 

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

376 metrics. 

377 sources_is_astropy : `bool`, optional 

378 Whether ``sources`` is an `astropy.table.Table` instance instead 

379 of an `lsst.afw.table.Catalog` instance. Default is `False` (the 

380 latter). 

381 """ 

382 nan = float("nan") 

383 summary.psfSigma = nan 

384 summary.psfIxx = nan 

385 summary.psfIyy = nan 

386 summary.psfIxy = nan 

387 summary.psfArea = nan 

388 summary.nPsfStar = 0 

389 summary.psfStarDeltaE1Median = nan 

390 summary.psfStarDeltaE2Median = nan 

391 summary.psfStarDeltaE1Scatter = nan 

392 summary.psfStarDeltaE2Scatter = nan 

393 summary.psfStarDeltaSizeMedian = nan 

394 summary.psfStarDeltaSizeScatter = nan 

395 summary.psfStarScaledDeltaSizeScatter = nan 

396 summary.maxDistToNearestPsf = nan 

397 summary.psfTraceRadiusDelta = nan 

398 summary.psfApFluxDelta = nan 

399 summary.psfApCorrSigmaScaledDelta = nan 

400 summary.starEMedian = nan 

401 summary.starUnNormalizedEMedian = nan 

402 

403 if psf is None: 

404 return 

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

406 summary.psfSigma = shape.getDeterminantRadius() 

407 summary.psfIxx = shape.getIxx() 

408 summary.psfIyy = shape.getIyy() 

409 summary.psfIxy = shape.getIxy() 

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

411 # The calculation of effective psf area is taken from 

412 # ls.st/srd Equation 1. 

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

414 

415 if image_mask is not None: 

416 psfApRadius = max(self.config.minPsfApRadiusPix, 3.0*summary.psfSigma) 

417 self.log.debug("Using radius of %.3f (pixels) for psfApFluxDelta metric", psfApRadius) 

418 psfTraceRadiusDelta, psfApFluxDelta = compute_psf_image_deltas( 

419 image_mask, 

420 psf, 

421 sampling=self.config.psfGridSampling, 

422 ap_radius_pix=psfApRadius, 

423 bad_mask_bits=self.config.psfBadMaskPlanes 

424 ) 

425 summary.psfTraceRadiusDelta = float(psfTraceRadiusDelta) 

426 summary.psfApFluxDelta = float(psfApFluxDelta) 

427 if image_ap_corr_map is not None: 

428 if self.config.psfApCorrFieldName not in image_ap_corr_map.keys(): 

429 self.log.warning(f"{self.config.psfApCorrFieldName} not found in " 

430 "image_ap_corr_map. Setting psfApCorrSigmaScaledDelta to NaN.") 

431 psfApCorrSigmaScaledDelta = nan 

432 else: 

433 image_ap_corr_field = image_ap_corr_map[self.config.psfApCorrFieldName] 

434 psfApCorrSigmaScaledDelta = compute_ap_corr_sigma_scaled_delta( 

435 image_mask, 

436 image_ap_corr_field, 

437 summary.psfSigma, 

438 sampling=self.config.psfGridSampling, 

439 bad_mask_bits=self.config.psfBadMaskPlanes, 

440 ) 

441 summary.psfApCorrSigmaScaledDelta = float(psfApCorrSigmaScaledDelta) 

442 

443 if sources is None: 

444 # No sources are available (as in some tests and rare cases where 

445 # the selection criteria in finalizeCharacterization lead to no 

446 # good sources). 

447 return 

448 

449 # Count the total number of psf stars used (prior to stats selection). 

450 nPsfStar = sources[self.config.starSelection].sum() 

451 summary.nPsfStar = int(nPsfStar) 

452 

453 psf_mask = self.starSelector.run(sources).selected 

454 nPsfStarsUsedInStats = psf_mask.sum() 

455 

456 if nPsfStarsUsedInStats == 0: 

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

458 # of 0 stars and NaN values. 

459 return 

460 

461 if sources_is_astropy: 

462 psf_cat = sources[psf_mask] 

463 else: 

464 psf_cat = sources[psf_mask].copy(deep=True) 

465 

466 starXX = psf_cat[self.config.starShape + '_xx'] 

467 starYY = psf_cat[self.config.starShape + '_yy'] 

468 starXY = psf_cat[self.config.starShape + '_xy'] 

469 psfXX = psf_cat[self.config.psfShape + '_xx'] 

470 psfYY = psf_cat[self.config.psfShape + '_yy'] 

471 psfXY = psf_cat[self.config.psfShape + '_xy'] 

472 

473 # Use the trace radius for the star size. 

474 starSize = np.sqrt(starXX/2. + starYY/2.) 

475 

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

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

478 starSizeMedian = np.median(starSize) 

479 

480 starE = np.sqrt(starE1**2.0 + starE2**2.0) 

481 starUnNormalizedE = np.sqrt((starXX - starYY)**2.0 + (2.0*starXY)**2.0) 

482 starEMedian = np.median(starE) 

483 starUnNormalizedEMedian = np.median(starUnNormalizedE) 

484 summary.starEMedian = float(starEMedian) 

485 summary.starUnNormalizedEMedian = float(starUnNormalizedEMedian) 

486 

487 # Use the trace radius for the psf size. 

488 psfSize = np.sqrt(psfXX/2. + psfYY/2.) 

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

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

491 

492 psfStarDeltaE1Median = np.median(starE1 - psfE1) 

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

494 psfStarDeltaE2Median = np.median(starE2 - psfE2) 

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

496 

497 psfStarDeltaSizeMedian = np.median(starSize - psfSize) 

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

499 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian 

500 

501 summary.psfStarDeltaE1Median = float(psfStarDeltaE1Median) 

502 summary.psfStarDeltaE2Median = float(psfStarDeltaE2Median) 

503 summary.psfStarDeltaE1Scatter = float(psfStarDeltaE1Scatter) 

504 summary.psfStarDeltaE2Scatter = float(psfStarDeltaE2Scatter) 

505 summary.psfStarDeltaSizeMedian = float(psfStarDeltaSizeMedian) 

506 summary.psfStarDeltaSizeScatter = float(psfStarDeltaSizeScatter) 

507 summary.psfStarScaledDeltaSizeScatter = float(psfStarScaledDeltaSizeScatter) 

508 

509 if image_mask is not None: 

510 maxDistToNearestPsf = maximum_nearest_psf_distance( 

511 image_mask, 

512 psf_cat, 

513 sampling=self.config.psfSampling, 

514 bad_mask_bits=self.config.psfBadMaskPlanes 

515 ) 

516 summary.maxDistToNearestPsf = float(maxDistToNearestPsf) 

517 

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

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

520 

521 Parameters 

522 ---------- 

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

524 Summary object to update in-place. 

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

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

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

528 bbox : `lsst.geom.Box2I` 

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

530 computed. 

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

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

533 the zenith distance. 

534 """ 

535 nan = float("nan") 

536 summary.raCorners = [nan]*4 

537 summary.decCorners = [nan]*4 

538 summary.ra = nan 

539 summary.dec = nan 

540 summary.pixelScale = nan 

541 summary.zenithDistance = nan 

542 

543 if wcs is None: 

544 return 

545 

546 sph_pts = wcs.pixelToSky(geom.Box2D(bbox).getCorners()) 

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

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

549 

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

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

552 summary.dec = sph_pt.getDec().asDegrees() 

553 summary.pixelScale = wcs.getPixelScale(bbox.getCenter()).asArcseconds() 

554 

555 date = visitInfo.getDate() 

556 

557 if date.isValid(): 

558 # We compute the zenithDistance at the center of the detector 

559 # rather than use the boresight value available via the visitInfo, 

560 # because the zenithDistance may vary significantly over a large 

561 # field of view. 

562 observatory = visitInfo.getObservatory() 

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

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

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

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

567 location=loc, format='mjd') 

568 coord = SkyCoord( 

569 summary.ra*units.degree, 

570 summary.dec*units.degree, 

571 obstime=obstime, 

572 location=loc, 

573 ) 

574 with warnings.catch_warnings(): 

575 warnings.simplefilter('ignore') 

576 altaz = coord.transform_to(AltAz) 

577 

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

579 

580 def update_photo_calib_stats(self, summary, photo_calib): 

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

582 calibration model. 

583 

584 Parameters 

585 ---------- 

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

587 Summary object to update in-place. 

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

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

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

591 """ 

592 if photo_calib is not None: 

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

594 else: 

595 summary.zeroPoint = float("nan") 

596 

597 def update_background_stats(self, summary, background): 

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

599 background model. 

600 

601 Parameters 

602 ---------- 

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

604 Summary object to update in-place. 

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

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

607 background will be reset (generally to NaN). 

608 

609 Notes 

610 ----- 

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

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

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

614 as well. 

615 """ 

616 if background is not None: 

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

618 for bg in background) 

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

620 else: 

621 summary.skyBg = float("nan") 

622 

623 def update_masked_image_stats(self, summary, masked_image): 

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

625 itself. 

626 

627 Parameters 

628 ---------- 

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

630 Summary object to update in-place. 

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

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

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

634 """ 

635 nan = float("nan") 

636 if masked_image is None: 

637 summary.skyNoise = nan 

638 summary.meanVar = nan 

639 return 

640 statsCtrl = afwMath.StatisticsControl() 

641 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

642 statsCtrl.setNumIter(self.config.clipIter) 

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

644 statsCtrl.setNanSafe(True) 

645 

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

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

648 summary.skyNoise = skyNoise 

649 

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

651 statsCtrl) 

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

653 summary.meanVar = meanVar 

654 

655 def update_effective_time_stats(self, summary, exposure): 

656 """Compute effective exposure time statistics to estimate depth. 

657 

658 The effective exposure time is the equivalent shutter open 

659 time that would be needed under nominal conditions to give the 

660 same signal-to-noise for a point source as what is achieved by 

661 the observation of interest. This metric combines measurements 

662 of the point-spread function, the sky brightness, and the 

663 transparency. It assumes that the observation is 

664 sky-background dominated. 

665 

666 .. _teff_definitions: 

667 

668 The effective exposure time and its subcomponents are defined in [1]_. 

669 

670 References 

671 ---------- 

672 

673 .. [1] Neilsen, E.H., Bernstein, G., Gruendl, R., and Kent, S. (2016). 

674 Limiting Magnitude, \tau, teff, and Image Quality in DES Year 1 

675 https://www.osti.gov/biblio/1250877/ 

676 

677 Parameters 

678 ---------- 

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

680 Summary object to update in-place. 

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

682 Exposure to grab band and exposure time metadata 

683 

684 """ 

685 nan = float("nan") 

686 summary.effTime = nan 

687 summary.effTimePsfSigmaScale = nan 

688 summary.effTimeSkyBgScale = nan 

689 summary.effTimeZeroPointScale = nan 

690 

691 exposureTime = exposure.getInfo().getVisitInfo().getExposureTime() 

692 filterLabel = exposure.getFilter() 

693 if (filterLabel is None) or (not filterLabel.hasBandLabel): 

694 band = None 

695 else: 

696 band = filterLabel.bandLabel 

697 

698 if band is None: 

699 self.log.warning("No band associated with exposure; effTime not calculated.") 

700 return 

701 

702 # PSF component 

703 if np.isnan(summary.psfSigma): 

704 self.log.debug("PSF sigma is NaN") 

705 f_eff = nan 

706 elif band not in self.config.fiducialPsfSigma: 

707 self.log.debug(f"Fiducial PSF value not found for {band}") 

708 f_eff = nan 

709 else: 

710 fiducialPsfSigma = self.config.fiducialPsfSigma[band] 

711 f_eff = (summary.psfSigma / fiducialPsfSigma)**-2 

712 

713 # Transparency component 

714 # Note: Assumes that the zeropoint includes the exposure time 

715 if np.isnan(summary.zeroPoint): 

716 self.log.debug("Zero point is NaN") 

717 c_eff = nan 

718 elif band not in self.config.fiducialZeroPoint: 

719 self.log.debug(f"Fiducial zero point value not found for {band}") 

720 c_eff = nan 

721 else: 

722 fiducialZeroPoint = self.config.fiducialZeroPoint[band] 

723 zeroPointDiff = fiducialZeroPoint - (summary.zeroPoint - 2.5*np.log10(exposureTime)) 

724 c_eff = min(10**(-2.0*(zeroPointDiff)/2.5), self.config.maxEffectiveTransparency) 

725 

726 # Sky brightness component (convert to cts/s) 

727 if np.isnan(summary.skyBg): 

728 self.log.debug("Sky background is NaN") 

729 b_eff = nan 

730 elif band not in self.config.fiducialSkyBackground: 

731 self.log.debug(f"Fiducial sky background value not found for {band}") 

732 b_eff = nan 

733 else: 

734 fiducialSkyBackground = self.config.fiducialSkyBackground[band] 

735 b_eff = fiducialSkyBackground/(summary.skyBg/exposureTime) 

736 

737 # Old effective exposure time scale factor 

738 # t_eff = f_eff * c_eff * b_eff 

739 

740 # New effective exposure time (seconds) from magnitude limit 

741 if band not in self.config.fiducialMagLim: 

742 self.log.debug(f"Fiducial magnitude limit not found for {band}") 

743 effectiveTime = nan 

744 elif band not in self.config.fiducialExpTime: 

745 self.log.debug(f"Fiducial exposure time not found for {band}") 

746 effectiveTime = nan 

747 else: 

748 effectiveTime = compute_effective_time(summary.magLim, 

749 self.config.fiducialMagLim[band], 

750 self.config.fiducialExpTime[band]) 

751 

752 # Output quantities 

753 summary.effTime = float(effectiveTime) 

754 summary.effTimePsfSigmaScale = float(f_eff) 

755 summary.effTimeSkyBgScale = float(b_eff) 

756 summary.effTimeZeroPointScale = float(c_eff) 

757 

758 def update_magnitude_limit_stats(self, summary, exposure): 

759 """Compute a summary statistic for the point-source magnitude limit at 

760 a given signal-to-noise ratio using exposure-level metadata. 

761 

762 The magnitude limit is calculated at a given SNR from the PSF, 

763 sky, zeropoint, and readnoise in accordance with SMTN-002 [1]_, 

764 LSE-40 [2]_, and DMTN-296 [3]_. 

765 

766 References 

767 ---------- 

768 

769 .. [1] "Calculating LSST limiting magnitudes and SNR" (SMTN-002) 

770 .. [2] "Photon Rates and SNR Calculations" (LSE-40) 

771 .. [3] "Calculations of Image and Catalog Depth" (DMTN-296) 

772 

773 

774 Parameters 

775 ---------- 

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

777 Summary object to update in-place. 

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

779 Exposure to grab band and exposure time metadata 

780 """ 

781 if exposure.getDetector() is None: 

782 summary.magLim = float("nan") 

783 return 

784 

785 # Calculate the average readnoise [e-] 

786 readNoiseList = list(ipIsr.getExposureReadNoises(exposure).values()) 

787 readNoise = np.nanmean(readNoiseList) 

788 if np.isnan(readNoise): 

789 readNoise = 0.0 

790 self.log.warning("Read noise set to NaN! Setting readNoise to 0.0 to proceed.") 

791 

792 # Calculate the average gain [e-/ADU] 

793 gainList = list(ipIsr.getExposureGains(exposure).values()) 

794 gain = np.nanmean(gainList) 

795 if np.isnan(gain): 

796 self.log.warning("Gain set to NaN! Setting magLim to NaN.") 

797 summary.magLim = float("nan") 

798 return 

799 

800 # Get the image units (default to 'adu' if metadata key absent) 

801 md = exposure.getMetadata() 

802 if md.get("LSST ISR UNITS", "adu") == "electron": 

803 gain = 1.0 

804 

805 # Convert readNoise to image units 

806 readNoise /= gain 

807 

808 # Calculate the limiting magnitude. 

809 # Note 1: Assumes that the image and readnoise have the same units 

810 # Note 2: Assumes that the zeropoint includes the exposure time 

811 magLim = compute_magnitude_limit(summary.psfArea, summary.skyBg, 

812 summary.zeroPoint, readNoise, 

813 gain, self.config.magLimSnr) 

814 

815 summary.magLim = float(magLim) 

816 

817 

818def maximum_nearest_psf_distance( 

819 image_mask, 

820 psf_cat, 

821 sampling=8, 

822 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"], 

823): 

824 """Compute the maximum distance of an unmasked pixel to its nearest PSF. 

825 

826 Parameters 

827 ---------- 

828 image_mask : `lsst.afw.image.Mask` 

829 The mask plane associated with the exposure. 

830 psf_cat : `lsst.afw.table.SourceCatalog` or `astropy.table.Table` 

831 Catalog containing only the stars used in the PSF modeling. 

832 sampling : `int` 

833 Sampling rate in each dimension to create the grid of points on which 

834 to evaluate the distance to the nearest PSF star. The tradeoff is 

835 between adequate sampling versus speed. 

836 bad_mask_bits : `list` [`str`] 

837 Mask bits required to be absent for a pixel to be considered 

838 "unmasked". 

839 

840 Returns 

841 ------- 

842 max_dist_to_nearest_psf : `float` 

843 The maximum distance (in pixels) of an unmasked pixel to its nearest 

844 PSF model star. 

845 """ 

846 mask_arr = image_mask.array[::sampling, ::sampling] 

847 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

848 good = ((mask_arr & bitmask) == 0) 

849 

850 x = np.arange(good.shape[1]) * sampling 

851 y = np.arange(good.shape[0]) * sampling 

852 xx, yy = np.meshgrid(x, y) 

853 

854 dist_to_nearest_psf = np.full(good.shape, np.inf) 

855 for psf in psf_cat: 

856 x_psf = psf["slot_Centroid_x"] 

857 y_psf = psf["slot_Centroid_y"] 

858 dist_to_nearest_psf = np.minimum(dist_to_nearest_psf, np.hypot(xx - x_psf, yy - y_psf)) 

859 unmasked_dists = dist_to_nearest_psf * good 

860 max_dist_to_nearest_psf = np.max(unmasked_dists) 

861 

862 return max_dist_to_nearest_psf 

863 

864 

865def compute_psf_image_deltas( 

866 image_mask, 

867 image_psf, 

868 sampling=96, 

869 ap_radius_pix=3.0, 

870 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"], 

871): 

872 """Compute the delta between the maximum and minimum model PSF trace radius 

873 values evaluated on a grid of points lying in the unmasked region of the 

874 image. 

875 

876 Parameters 

877 ---------- 

878 image_mask : `lsst.afw.image.Mask` 

879 The mask plane associated with the exposure. 

880 image_psf : `lsst.afw.detection.Psf` 

881 The PSF model associated with the exposure. 

882 sampling : `int`, optional 

883 Sampling rate in each dimension to create the grid of points at which 

884 to evaluate ``image_psf``s trace radius value. The tradeoff is between 

885 adequate sampling versus speed. 

886 ap_radius_pix : `float`, optional 

887 Radius in pixels of the aperture on which to measure the flux of the 

888 PSF model. 

889 bad_mask_bits : `list` [`str`], optional 

890 Mask bits required to be absent for a pixel to be considered 

891 "unmasked". 

892 

893 Returns 

894 ------- 

895 psf_trace_radius_delta, psf_ap_flux_delta : `float` 

896 The delta (in pixels) between the maximum and minimum model PSF trace 

897 radius values and the PSF aperture fluxes (with aperture radius of 

898 max(2, 3*psfSigma)) evaluated on the x,y-grid subsampled on the 

899 unmasked detector pixels by a factor of ``sampling``. If both the 

900 model PSF trace radius value and aperture flux value on the grid 

901 evaluate to NaN, then NaNs are returned immediately. 

902 """ 

903 psf_trace_radius_list = [] 

904 psf_ap_flux_list = [] 

905 mask_arr = image_mask.array[::sampling, ::sampling] 

906 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

907 good = ((mask_arr & bitmask) == 0) 

908 

909 x = np.arange(good.shape[1]) * sampling 

910 y = np.arange(good.shape[0]) * sampling 

911 xx, yy = np.meshgrid(x, y) 

912 

913 for x_mesh, y_mesh, good_mesh in zip(xx, yy, good): 

914 for x_point, y_point, is_good in zip(x_mesh, y_mesh, good_mesh): 

915 if is_good: 

916 position = geom.Point2D(x_point, y_point) 

917 psf_trace_radius = image_psf.computeShape(position).getTraceRadius() 

918 psf_ap_flux = image_psf.computeApertureFlux(ap_radius_pix, position) 

919 if ~np.isfinite(psf_trace_radius) and ~np.isfinite(psf_ap_flux): 

920 return float("nan"), float("nan") 

921 psf_trace_radius_list.append(psf_trace_radius) 

922 psf_ap_flux_list.append(psf_ap_flux) 

923 

924 psf_trace_radius_delta = np.max(psf_trace_radius_list) - np.min(psf_trace_radius_list) 

925 if np.any(np.asarray(psf_ap_flux_list) < 0.0): # Consider any -ve flux entry as "bad". 

926 psf_ap_flux_delta = float("nan") 

927 else: 

928 psf_ap_flux_delta = np.max(psf_ap_flux_list) - np.min(psf_ap_flux_list) 

929 

930 return psf_trace_radius_delta, psf_ap_flux_delta 

931 

932 

933def compute_ap_corr_sigma_scaled_delta( 

934 image_mask, 

935 image_ap_corr_field, 

936 psfSigma, 

937 sampling=96, 

938 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"], 

939): 

940 """Compute the delta between the maximum and minimum aperture correction 

941 values scaled (divided) by ``psfSigma`` for the given field representation, 

942 ``image_ap_corr_field`` evaluated on a grid of points lying in the 

943 unmasked region of the image. 

944 

945 Parameters 

946 ---------- 

947 image_mask : `lsst.afw.image.Mask` 

948 The mask plane associated with the exposure. 

949 image_ap_corr_field : `lsst.afw.math.ChebyshevBoundedField` 

950 The ChebyshevBoundedField representation of the aperture correction 

951 of interest for the exposure. 

952 psfSigma : `float` 

953 The PSF model second-moments determinant radius (center of chip) 

954 in pixels. 

955 sampling : `int`, optional 

956 Sampling rate in each dimension to create the grid of points at which 

957 to evaluate ``image_psf``s trace radius value. The tradeoff is between 

958 adequate sampling versus speed. 

959 bad_mask_bits : `list` [`str`], optional 

960 Mask bits required to be absent for a pixel to be considered 

961 "unmasked". 

962 

963 Returns 

964 ------- 

965 ap_corr_sigma_scaled_delta : `float` 

966 The delta between the maximum and minimum of the (multiplicative) 

967 aperture correction values scaled (divided) by ``psfSigma`` evaluated 

968 on the x,y-grid subsampled on the unmasked detector pixels by a factor 

969 of ``sampling``. If the aperture correction evaluates to NaN on any 

970 of the grid points, this is set to NaN. 

971 """ 

972 mask_arr = image_mask.array[::sampling, ::sampling] 

973 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

974 good = ((mask_arr & bitmask) == 0) 

975 

976 x = np.arange(good.shape[1], dtype=np.float64) * sampling 

977 y = np.arange(good.shape[0], dtype=np.float64) * sampling 

978 xx, yy = np.meshgrid(x, y) 

979 

980 ap_corr = image_ap_corr_field.evaluate(xx.ravel(), yy.ravel()).reshape(xx.shape) 

981 ap_corr_good = ap_corr[good] 

982 if ~np.isfinite(ap_corr_good).all(): 

983 ap_corr_sigma_scaled_delta = float("nan") 

984 else: 

985 ap_corr_sigma_scaled_delta = (np.max(ap_corr_good) - np.min(ap_corr_good))/psfSigma 

986 

987 return ap_corr_sigma_scaled_delta 

988 

989 

990def compute_magnitude_limit( 

991 psfArea, 

992 skyBg, 

993 zeroPoint, 

994 readNoise, 

995 gain, 

996 snr 

997): 

998 """Compute the expected point-source magnitude limit at a given 

999 signal-to-noise ratio given the exposure-level metadata. Based on 

1000 the signal-to-noise formula provided in SMTN-002 (see LSE-40 for 

1001 more details on the calculation). 

1002 

1003 SNR = C / sqrt( C/g + (B/g + sigma_inst**2) * neff ) 

1004 

1005 where C is the counts from the source, B is counts from the (sky) 

1006 background, sigma_inst is the instrumental (read) noise, neff is 

1007 the effective size of the PSF, and g is the gain in e-/ADU. Note 

1008 that input values of ``skyBg``, ``zeroPoint``, and ``readNoise`` 

1009 should all consistently be in electrons or ADU. 

1010 

1011 Parameters 

1012 ---------- 

1013 psfArea : `float` 

1014 The effective area of the PSF [pix]. 

1015 skyBg : `float` 

1016 The sky background counts for the exposure [ADU or e-]. 

1017 zeroPoint : `float` 

1018 The zeropoint (includes exposure time) [ADU or e-]. 

1019 readNoise : `float` 

1020 The instrumental read noise for the exposure [ADU or e-]. 

1021 gain : `float` 

1022 The instrumental gain for the exposure [e-/ADU]. The gain should 

1023 be 1.0 if the skyBg, zeroPoint, and readNoise are in e-. 

1024 snr : `float` 

1025 Signal-to-noise ratio at which magnitude limit is calculated. 

1026 

1027 Returns 

1028 ------- 

1029 magnitude_limit : `float` 

1030 The expected magnitude limit at the given signal to noise. 

1031 """ 

1032 # Effective number of pixels within the PSF calculated directly 

1033 # from the PSF model. 

1034 neff = psfArea 

1035 

1036 # Instrumental noise (read noise only) 

1037 sigma_inst = readNoise 

1038 

1039 # Total background counts derived from Eq. 45 of LSE-40 

1040 background = (skyBg/gain + sigma_inst**2) * neff 

1041 # Source counts to achieve the desired SNR (from quadratic formula) 

1042 # Note typo in gain exponent in Eq. 45 of LSE-40 (DM-53234) 

1043 source = (snr**2)/(2*gain) + np.sqrt((snr**4)/(4*gain**2) + snr**2 * background) 

1044 

1045 # Convert to a magnitude using the zeropoint. 

1046 # Note: Zeropoint currently includes exposure time 

1047 magnitude_limit = -2.5*np.log10(source) + zeroPoint 

1048 

1049 return magnitude_limit 

1050 

1051 

1052def psf_sigma_to_psf_area(psfSigma, pixelScale): 

1053 """Convert psfSigma [pixels] to an approximate psfArea [pixel^2] as defined in LSE-40. 

1054 This is the same implementation followed by SMTN-002 to estimate SNR=5 magnitude limits. 

1055 

1056 Parameters 

1057 ---------- 

1058 psfSigma : `float` 

1059 The PSF sigma value [pix]. 

1060 pixelScale : `float` 

1061 The pixel scale [arcsec/pix]. 

1062 

1063 Returns 

1064 ------- 

1065 psf_area : `float` 

1066 Approximation of the PSF area [pix^2] 

1067 """ 

1068 # Follow SMTN-002 to convert to geometric and effective FWHM 

1069 fwhm_geom = psfSigma * 2.355 * pixelScale 

1070 fwhm_eff = (fwhm_geom - 0.052) / 0.822 

1071 # Area prefactor comes from LSE-40 

1072 psf_area = 2.266 * (fwhm_eff / pixelScale)**2 

1073 return psf_area 

1074 

1075 

1076def compute_effective_time( 

1077 magLim, 

1078 fiducialMagLim, 

1079 fiducialExpTime 

1080): 

1081 """Compute the effective exposure time from m5 following the prescription described in SMTN-296. 

1082 

1083 teff = 10**(0.8 * (magLim - fiducialMagLim) ) * fiducialExpTime 

1084 

1085 where `magLim` is the magnitude limit, `fiducialMagLim` is the fiducial magnitude limit calculated from 

1086 the fiducial values of the ``psfArea``, ``skyBg``, ``zeroPoint``, and ``readNoise``, and 

1087 `fiducialExpTime` is the fiducial exposure time (s). 

1088 

1089 Parameters 

1090 ---------- 

1091 magLim : `float` 

1092 The measured magnitude limit [mag]. 

1093 fiducialMagLim : `float` 

1094 The fiducial magnitude limit [mag]. 

1095 fiducialExpTime : `float` 

1096 The fiducial exposure time [s]. 

1097 

1098 Returns 

1099 ------- 

1100 effectiveTime : `float` 

1101 The effective exposure time. 

1102 """ 

1103 effectiveTime = 10**(0.8 * (magLim - fiducialMagLim)) * fiducialExpTime 

1104 return effectiveTime