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

528 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 09:30 +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.meas.algorithms.computeExPsf import ComputeExPsfTask 

39from lsst.utils.timer import timeMethod 

40import lsst.ip.isr as ipIsr 

41 

42 

43class ComputeExposureSummaryStatsConfig(pexConfig.Config): 

44 """Config for ComputeExposureSummaryTask""" 

45 sigmaClip = pexConfig.Field( 

46 dtype=float, 

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

48 default=3.0, 

49 ) 

50 clipIter = pexConfig.Field( 

51 dtype=int, 

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

53 default=2, 

54 ) 

55 badMaskPlanes = pexConfig.ListField( 

56 dtype=str, 

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

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

59 ) 

60 starSelection = pexConfig.Field( 

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

62 dtype=str, 

63 default="calib_psf_used", 

64 ) 

65 starSelector = pexConfig.ConfigurableField( 

66 target=ScienceSourceSelectorTask, 

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

68 ) 

69 starShape = pexConfig.Field( 

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

71 dtype=str, 

72 default="slot_Shape" 

73 ) 

74 psfShape = pexConfig.Field( 

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

76 dtype=str, 

77 default="slot_PsfShape" 

78 ) 

79 starHigherOrderMomentBase = pexConfig.Field( 

80 doc="Base name of the columns from which to obtain the higher-order moments.", 

81 dtype=str, 

82 default="ext_shapeHSM_HigherOrderMomentsSource", 

83 ) 

84 psfSampling = pexConfig.Field( 

85 dtype=int, 

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

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

88 default=8, 

89 ) 

90 psfGridSampling = pexConfig.Field( 

91 dtype=int, 

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

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

94 default=96, 

95 ) 

96 minPsfApRadiusPix = pexConfig.Field( 

97 dtype=float, 

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

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

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

101 default=2.0, 

102 ) 

103 psfApCorrFieldName = pexConfig.Field( 

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

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

106 dtype=str, 

107 default="base_PsfFlux_instFlux" 

108 ) 

109 psfBadMaskPlanes = pexConfig.ListField( 

110 dtype=str, 

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

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

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

114 ) 

115 fiducialSkyBackground = pexConfig.DictField( 

116 keytype=str, 

117 itemtype=float, 

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

119 "Keyed by band.", 

120 default={"u": 1.0, "g": 1.0, "r": 1.0, "i": 1.0, "z": 1.0, "y": 1.0}, 

121 ) 

122 fiducialPsfSigma = pexConfig.DictField( 

123 keytype=str, 

124 itemtype=float, 

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

126 "Keyed by band.", 

127 default={"u": 1.0, "g": 1.0, "r": 1.0, "i": 1.0, "z": 1.0, "y": 1.0}, 

128 ) 

129 fiducialZeroPoint = pexConfig.DictField( 

130 keytype=str, 

131 itemtype=float, 

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

133 "Keyed by band.", 

134 default={"u": 25.0, "g": 25.0, "r": 25.0, "i": 25.0, "z": 25.0, "y": 25.0}, 

135 ) 

136 fiducialReadNoise = pexConfig.DictField( 

137 keytype=str, 

138 itemtype=float, 

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

140 "Keyed by band.", 

141 default={"u": 9.0, "g": 9.0, "r": 9.0, "i": 9.0, "z": 9.0, "y": 9.0}, 

142 ) 

143 fiducialExpTime = pexConfig.DictField( 

144 keytype=str, 

145 itemtype=float, 

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

147 "Keyed by band.", 

148 default={"u": 30.0, "g": 30.0, "r": 30.0, "i": 30.0, "z": 30.0, "y": 30.0}, 

149 ) 

150 fiducialMagLim = pexConfig.DictField( 

151 keytype=str, 

152 itemtype=float, 

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

154 "Keyed by band.", 

155 default={"u": 25.0, "g": 25.0, "r": 25.0, "i": 25.0, "z": 25.0, "y": 25.0}, 

156 ) 

157 maxEffectiveTransparency = pexConfig.Field( 

158 dtype=float, 

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

160 default=float("inf") 

161 ) 

162 magLimSnr = pexConfig.Field( 

163 dtype=float, 

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

165 default=5.0 

166 ) 

167 psfTE1 = pexConfig.ConfigurableField( 

168 target=ComputeExPsfTask, 

169 doc="Use treecorr for computing scalar value of TE1.", 

170 ) 

171 psfTE2 = pexConfig.ConfigurableField( 

172 target=ComputeExPsfTask, 

173 doc="Use treecorr for computing scalar value of TE2.", 

174 ) 

175 psfTE3 = pexConfig.ConfigurableField( 

176 target=ComputeExPsfTask, 

177 doc="Use treecorr for computing scalar value of TE3.", 

178 ) 

179 psfTE4 = pexConfig.ConfigurableField( 

180 target=ComputeExPsfTask, 

181 doc="Use treecorr for computing scalar value of TE4.", 

182 ) 

183 

184 def setDefaults(self): 

185 super().setDefaults() 

186 

187 self.starSelector.setDefaults() 

188 self.starSelector.doFlags = True 

189 self.starSelector.doSignalToNoise = True 

190 self.starSelector.doUnresolved = False 

191 self.starSelector.doIsolated = False 

192 self.starSelector.doRequireFiniteRaDec = False 

193 self.starSelector.doRequirePrimary = False 

194 

195 self.starSelector.signalToNoise.minimum = 50.0 

196 self.starSelector.signalToNoise.maximum = 1000.0 

197 

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

199 # Select stars used for PSF modeling. 

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

201 

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

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

204 

205 min_theta = [1e-6, 5.0, 1e-6, 5.0] 

206 max_theta = [1.0, 100.0, 5.0, 20.0] 

207 psfTEx = [ 

208 self.psfTE1, 

209 self.psfTE2, 

210 self.psfTE3, 

211 self.psfTE4, 

212 ] 

213 

214 for tex, mint, maxt in zip(psfTEx, min_theta, max_theta): 

215 tex.setDefaults() 

216 tex.treecorr.min_sep = mint / 60.0 

217 tex.treecorr.max_sep = maxt / 60.0 

218 tex.treecorr.nbins = 1 

219 tex.treecorr.bin_type = "Linear" 

220 tex.treecorr.sep_units = "degree" 

221 

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

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

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

225 

226 Parameters 

227 ---------- 

228 band : `str` 

229 The band of interest 

230 pixelScale : `float` 

231 The pixel scale [arcsec/pix] 

232 gain : `float` 

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

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

235 

236 Returns 

237 ------- 

238 magnitude_limit : `float` 

239 The fiducial magnitude limit calculated from fiducial values. 

240 """ 

241 nan = float("nan") 

242 

243 # Fiducials from config 

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

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

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

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

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

249 magLimSnr = self.magLimSnr 

250 

251 # Derived fiducial quantities 

252 fiducialPsfArea = psf_sigma_to_psf_area(fiducialPsfSigma, pixelScale) 

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

254 fiducialSkyBg = fiducialSkyBackground * fiducialExpTime 

255 fiducialReadNoise /= gain 

256 

257 # Calculate the fiducial magnitude limit 

258 fiducialMagLim = compute_magnitude_limit(fiducialPsfArea, 

259 fiducialSkyBg, 

260 fiducialZeroPoint, 

261 fiducialReadNoise, 

262 gain, 

263 magLimSnr) 

264 

265 return fiducialMagLim 

266 

267 

268class ComputeExposureSummaryStatsTask(pipeBase.Task): 

269 """Task to compute exposure summary statistics. 

270 

271 This task computes various quantities suitable for DPDD and other 

272 downstream processing at the detector centers, including: 

273 - expTime 

274 - psfSigma 

275 - psfArea 

276 - psfIxx 

277 - psfIyy 

278 - psfIxy 

279 - ra 

280 - dec 

281 - pixelScale (arcsec/pixel) 

282 - zenithDistance 

283 - zeroPoint 

284 - skyBg 

285 - skyNoise 

286 - meanVar 

287 - raCorners 

288 - decCorners 

289 - astromOffsetMean 

290 - astromOffsetStd 

291 

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

293 - psfStarDeltaE1Median 

294 - psfStarDeltaE2Median 

295 - psfStarDeltaE1Scatter 

296 - psfStarDeltaE2Scatter 

297 - psfStarDeltaSizeMedian 

298 - psfStarDeltaSizeScatter 

299 - psfStarScaledDeltaSizeScatter 

300 

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

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

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

304 - maxDistToNearestPsf 

305 - psfTraceRadiusDelta 

306 - psfApFluxDelta 

307 

308 These quantities are computed as part of: 

309 https://rubinobs.atlassian.net/browse/DM-40780 

310 

311 - psfTE1e1 

312 - psfTE1e2 

313 - psfTE1ex 

314 - psfTE2e1 

315 - psfTE2e2 

316 - psfTE2ex 

317 - psfTE3e1 

318 - psfTE3e2 

319 - psfTE3ex 

320 - psfTE4e1 

321 - psfTE4e2 

322 - psfTE4ex 

323 

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

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

326 corrections across a given detector: 

327 - psfApCorrSigmaScaledDelta 

328 

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

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

331 measure of the deviation from a circular shape): 

332 - starEMedian 

333 - starUnNormalizedEMedian 

334 

335 These quantities are computed to assess depth: 

336 - effTime 

337 - effTimePsfSigmaScale 

338 - effTimeSkyBgScale 

339 - effTimeZeroPointScale 

340 - magLim 

341 """ 

342 ConfigClass = ComputeExposureSummaryStatsConfig 

343 _DefaultName = "computeExposureSummaryStats" 

344 

345 def __init__(self, **kwargs): 

346 super().__init__(**kwargs) 

347 

348 self.makeSubtask("starSelector") 

349 self.makeSubtask("psfTE1") 

350 self.makeSubtask("psfTE2") 

351 self.makeSubtask("psfTE3") 

352 self.makeSubtask("psfTE4") 

353 self._isTEXComputationDone = False 

354 

355 @timeMethod 

356 def run(self, exposure, sources, background, summary=None): 

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

358 background. 

359 

360 Parameters 

361 ---------- 

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

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

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

365 summary : `lsst.afw.image.ExposureSummary`, optional 

366 Summary object to be appended to if not `None`. If `None` a new 

367 summary object will be created. 

368 

369 Returns 

370 ------- 

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

372 """ 

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

374 

375 if summary is None: 

376 summary = afwImage.ExposureSummaryStats() 

377 

378 # Set exposure time. 

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

380 summary.expTime = exposureTime 

381 

382 bbox = exposure.getBBox() 

383 

384 psf = exposure.getPsf() 

385 self.update_psf_stats( 

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

387 ) 

388 

389 wcs = exposure.getWcs() 

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

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

392 

393 photoCalib = exposure.getPhotoCalib() 

394 self.update_photo_calib_stats(summary, photoCalib) 

395 

396 self.update_background_stats(summary, background) 

397 

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

399 

400 self.update_magnitude_limit_stats(summary, exposure) 

401 

402 self.update_effective_time_stats(summary, exposure) 

403 

404 md = exposure.getMetadata() 

405 if "PSF_ADAPTIVE_THRESHOLD_VALUE" in md: 

406 summary.psfAdaptiveThresholdValue = md["PSF_ADAPTIVE_THRESHOLD_VALUE"] 

407 if "PSF_ADAPTIVE_INCLUDE_THRESHOLD_MULTIPLIER" in md: 

408 summary.psfAdaptiveIncludeThresholdMultiplier = md["PSF_ADAPTIVE_INCLUDE_THRESHOLD_MULTIPLIER"] 

409 if "REF_CAT_SOURCE_DENSITY" in md: 

410 summary.refCatSourceDensity = md["REF_CAT_SOURCE_DENSITY"] 

411 if "SFM_ASTROM_OFFSET_MEAN" in md: 

412 summary.astromOffsetMean = md["SFM_ASTROM_OFFSET_MEAN"] 

413 summary.astromOffsetStd = md["SFM_ASTROM_OFFSET_STD"] 

414 

415 return summary 

416 

417 def update_psf_stats( 

418 self, 

419 summary, 

420 psf, 

421 bbox, 

422 sources=None, 

423 image_mask=None, 

424 image_ap_corr_map=None, 

425 sources_is_astropy=False, 

426 ): 

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

428 

429 Parameters 

430 ---------- 

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

432 Summary object to update in-place. 

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

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

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

436 bbox : `lsst.geom.Box2I` 

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

438 computed. 

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

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

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

442 The type of this table must correspond to the 

443 ``sources_is_astropy`` argument. 

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

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

446 metrics. 

447 sources_is_astropy : `bool`, optional 

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

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

450 latter). 

451 """ 

452 nan = float("nan") 

453 summary.psfSigma = nan 

454 summary.psfIxx = nan 

455 summary.psfIyy = nan 

456 summary.psfIxy = nan 

457 summary.psfArea = nan 

458 summary.nPsfStar = 0 

459 summary.psfStarDeltaE1Median = nan 

460 summary.psfStarDeltaE2Median = nan 

461 summary.psfStarDeltaE1Scatter = nan 

462 summary.psfStarDeltaE2Scatter = nan 

463 summary.psfStarDeltaSizeMedian = nan 

464 summary.psfStarDeltaSizeScatter = nan 

465 summary.psfStarScaledDeltaSizeScatter = nan 

466 summary.maxDistToNearestPsf = nan 

467 summary.psfTraceRadiusDelta = nan 

468 summary.psfApFluxDelta = nan 

469 summary.psfApCorrSigmaScaledDelta = nan 

470 summary.starEMedian = nan 

471 summary.starUnNormalizedEMedian = nan 

472 summary.starComa1Median = nan 

473 summary.starComa2Median = nan 

474 summary.starTrefoil1Median = nan 

475 summary.starTrefoil2Median = nan 

476 summary.starKurtosisMedian = nan 

477 summary.starE41Median = nan 

478 summary.starE42Median = nan 

479 

480 if psf is None: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true

481 return 

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

483 summary.psfSigma = shape.getDeterminantRadius() 

484 summary.psfIxx = shape.getIxx() 

485 summary.psfIyy = shape.getIyy() 

486 summary.psfIxy = shape.getIxy() 

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

488 # The calculation of effective psf area is taken from 

489 # ls.st/srd Equation 1. 

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

491 

492 if image_mask is not None: 492 ↛ 520line 492 didn't jump to line 520 because the condition on line 492 was always true

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

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

495 psfTraceRadiusDelta, psfApFluxDelta = compute_psf_image_deltas( 

496 image_mask, 

497 psf, 

498 sampling=self.config.psfGridSampling, 

499 ap_radius_pix=psfApRadius, 

500 bad_mask_bits=self.config.psfBadMaskPlanes 

501 ) 

502 summary.psfTraceRadiusDelta = float(psfTraceRadiusDelta) 

503 summary.psfApFluxDelta = float(psfApFluxDelta) 

504 if image_ap_corr_map is not None: 

505 if self.config.psfApCorrFieldName not in image_ap_corr_map.keys(): 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true

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

507 "image_ap_corr_map. Setting psfApCorrSigmaScaledDelta to NaN.") 

508 psfApCorrSigmaScaledDelta = nan 

509 else: 

510 image_ap_corr_field = image_ap_corr_map[self.config.psfApCorrFieldName] 

511 psfApCorrSigmaScaledDelta = compute_ap_corr_sigma_scaled_delta( 

512 image_mask, 

513 image_ap_corr_field, 

514 summary.psfSigma, 

515 sampling=self.config.psfGridSampling, 

516 bad_mask_bits=self.config.psfBadMaskPlanes, 

517 ) 

518 summary.psfApCorrSigmaScaledDelta = float(psfApCorrSigmaScaledDelta) 

519 

520 if sources is None: 

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

522 # the selection criteria in finalizeCharacterization lead to no 

523 # good sources). 

524 return 

525 

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

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

528 summary.nPsfStar = int(nPsfStar) 

529 

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

531 nPsfStarsUsedInStats = psf_mask.sum() 

532 

533 if nPsfStarsUsedInStats == 0: 533 ↛ 536line 533 didn't jump to line 536 because the condition on line 533 was never true

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

535 # of 0 stars and NaN values. 

536 return 

537 

538 if sources_is_astropy: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true

539 psf_cat = sources[psf_mask] 

540 else: 

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

542 

543 starXX = psf_cat[self.config.starShape + "_xx"] 

544 starYY = psf_cat[self.config.starShape + "_yy"] 

545 starXY = psf_cat[self.config.starShape + "_xy"] 

546 psfXX = psf_cat[self.config.psfShape + "_xx"] 

547 psfYY = psf_cat[self.config.psfShape + "_yy"] 

548 psfXY = psf_cat[self.config.psfShape + "_xy"] 

549 

550 # Use the trace radius for the star size. 

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

552 

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

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

555 starSizeMedian = np.median(starSize) 

556 

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

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

559 starEMedian = np.median(starE) 

560 starUnNormalizedEMedian = np.median(starUnNormalizedE) 

561 summary.starEMedian = float(starEMedian) 

562 summary.starUnNormalizedEMedian = float(starUnNormalizedEMedian) 

563 

564 # Use the trace radius for the psf size. 

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

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

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

568 

569 e1Residuals = starE1 - psfE1 

570 e2Residuals = starE2 - psfE2 

571 

572 psfStarDeltaE1Median = np.median(e1Residuals) 

573 psfStarDeltaE1Scatter = sigmaMad(e1Residuals, scale='normal') 

574 psfStarDeltaE2Median = np.median(e2Residuals) 

575 psfStarDeltaE2Scatter = sigmaMad(e2Residuals, scale='normal') 

576 

577 psfStarDeltaSizeMedian = np.median(starSize - psfSize) 

578 psfStarDeltaSizeScatter = sigmaMad(starSize - psfSize, scale="normal") 

579 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian 

580 

581 summary.psfStarDeltaE1Median = float(psfStarDeltaE1Median) 

582 summary.psfStarDeltaE2Median = float(psfStarDeltaE2Median) 

583 summary.psfStarDeltaE1Scatter = float(psfStarDeltaE1Scatter) 

584 summary.psfStarDeltaE2Scatter = float(psfStarDeltaE2Scatter) 

585 summary.psfStarDeltaSizeMedian = float(psfStarDeltaSizeMedian) 

586 summary.psfStarDeltaSizeScatter = float(psfStarDeltaSizeScatter) 

587 summary.psfStarScaledDeltaSizeScatter = float(psfStarScaledDeltaSizeScatter) 

588 

589 # Higher-order moments and derived metrics. 

590 if sources_is_astropy: 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true

591 columnNames = psf_cat.colnames 

592 else: 

593 columnNames = psf_cat.schema.getNames() 

594 if self.config.starHigherOrderMomentBase + "_03" in columnNames: 594 ↛ 595line 594 didn't jump to line 595 because the condition on line 594 was never true

595 starM03 = psf_cat[self.config.starHigherOrderMomentBase + "_03"] 

596 starM12 = psf_cat[self.config.starHigherOrderMomentBase + "_12"] 

597 starM21 = psf_cat[self.config.starHigherOrderMomentBase + "_21"] 

598 starM30 = psf_cat[self.config.starHigherOrderMomentBase + "_30"] 

599 starM04 = psf_cat[self.config.starHigherOrderMomentBase + "_04"] 

600 starM13 = psf_cat[self.config.starHigherOrderMomentBase + "_13"] 

601 starM22 = psf_cat[self.config.starHigherOrderMomentBase + "_22"] 

602 starM31 = psf_cat[self.config.starHigherOrderMomentBase + "_31"] 

603 starM40 = psf_cat[self.config.starHigherOrderMomentBase + "_40"] 

604 

605 starComa1Median = np.nanmedian(starM30 + starM12) 

606 starComa2Median = np.nanmedian(starM21 + starM03) 

607 starTrefoil1Median = np.nanmedian(starM30 - 3.0*starM12) 

608 starTrefoil2Median = np.nanmedian(3.0*starM21 - starM03) 

609 starKurtosisMedian = np.nanmedian(starM40 + 2.0*starM22 + starM04) 

610 starE41Median = np.nanmedian(starM40 - starM04) 

611 starE42Median = np.nanmedian(2.0*(starM31 + starM13)) 

612 

613 summary.starComa1Median = float(starComa1Median) 

614 summary.starComa2Median = float(starComa2Median) 

615 summary.starTrefoil1Median = float(starTrefoil1Median) 

616 summary.starTrefoil2Median = float(starTrefoil2Median) 

617 summary.starKurtosisMedian = float(starKurtosisMedian) 

618 summary.starE41Median = float(starE41Median) 

619 summary.starE42Median = float(starE42Median) 

620 else: 

621 self.log.warning("Higher-order moments with base column name %s not found in source " 

622 "catalog. Setting the derived metrics (i.e. coma1[2], trefoil1[2], " 

623 "kurtosis, e4_1, and e4_2) to nan.", self.config.starHigherOrderMomentBase) 

624 

625 if image_mask is not None: 625 ↛ exitline 625 didn't return from function 'update_psf_stats' because the condition on line 625 was always true

626 maxDistToNearestPsf = maximum_nearest_psf_distance( 

627 image_mask, 

628 psf_cat, 

629 sampling=self.config.psfSampling, 

630 bad_mask_bits=self.config.psfBadMaskPlanes 

631 ) 

632 summary.maxDistToNearestPsf = float(maxDistToNearestPsf) 

633 

634 def comp_psf_TEX_visit_level(self, summary, sources, sources_is_astropy=False): 

635 """Compute all summary-statistic fields at visit level for TEx metric of PSF. 

636 

637 Parameters 

638 ---------- 

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

640 Summary object to update in-place. 

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

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

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

644 The type of this table must correspond to the 

645 ``sources_is_astropy`` argument. 

646 sources_is_astropy : `bool`, optional 

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

648 of an `lsst.afw.table.Catalog` instance. 

649 """ 

650 

651 if self._isTEXComputationDone: 

652 

653 summary.psfTE1e1 = self.psfTE1e1 

654 summary.psfTE1e2 = self.psfTE1e2 

655 summary.psfTE1ex = self.psfTE1ex 

656 summary.psfTE2e1 = self.psfTE2e1 

657 summary.psfTE2e2 = self.psfTE2e2 

658 summary.psfTE2ex = self.psfTE2ex 

659 summary.psfTE3e1 = self.psfTE3e1 

660 summary.psfTE3e2 = self.psfTE3e2 

661 summary.psfTE3ex = self.psfTE3ex 

662 summary.psfTE4e1 = self.psfTE4e1 

663 summary.psfTE4e2 = self.psfTE4e2 

664 summary.psfTE4ex = self.psfTE4ex 

665 

666 else: 

667 

668 self._isTEXComputationDone = True 

669 

670 nan = float("nan") 

671 summary.psfTE1e1, self.psfTE1e1 = nan, nan 

672 summary.psfTE1e2, self.psfTE1e2 = nan, nan 

673 summary.psfTE1ex, self.psfTE1ex = nan, nan 

674 summary.psfTE2e1, self.psfTE2e1 = nan, nan 

675 summary.psfTE2e2, self.psfTE2e2 = nan, nan 

676 summary.psfTE2ex, self.psfTE2ex = nan, nan 

677 summary.psfTE3e1, self.psfTE3e1 = nan, nan 

678 summary.psfTE3e2, self.psfTE3e2 = nan, nan 

679 summary.psfTE3ex, self.psfTE3ex = nan, nan 

680 summary.psfTE4e1, self.psfTE4e1 = nan, nan 

681 summary.psfTE4e2, self.psfTE4e2 = nan, nan 

682 summary.psfTE4ex, self.psfTE4ex = nan, nan 

683 

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

685 

686 nPsfStarsUsedInStats = psf_mask.sum() 

687 

688 if nPsfStarsUsedInStats == 0: 

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

690 # of 0 stars and NaN values. 

691 return 

692 

693 if sources_is_astropy: 

694 psf_cat = sources[psf_mask] 

695 else: 

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

697 

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

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

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

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

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

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

704 

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

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

707 

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

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

710 

711 e1Residuals = starE1 - psfE1 

712 e2Residuals = starE2 - psfE2 

713 

714 # Comp TEx 

715 ra = psf_cat["coord_ra"].to(units.deg) 

716 dec = psf_cat["coord_dec"].to(units.deg) 

717 

718 psfTEx = { 

719 "TE1": self.psfTE1, 

720 "TE2": self.psfTE2, 

721 } 

722 

723 gatherE12Stat = { 

724 "TE1": {"E1": np.nan, "E2": np.nan, "Ex": np.nan, }, 

725 "TE2": {"E1": np.nan, "E2": np.nan, "Ex": np.nan, }, 

726 } 

727 

728 isNotNan = np.array([True] * len(ra)) 

729 isNotNan &= np.isfinite(ra) 

730 isNotNan &= np.isfinite(dec) 

731 isNotNan &= np.isfinite(e1Residuals) 

732 isNotNan &= np.isfinite(e2Residuals) 

733 

734 if np.sum(isNotNan) >= 2: 

735 # TE1 and TE2 computation, over visit. 

736 for TEX in ["TE1", "TE2"]: 

737 

738 output = psfTEx[TEX].run( 

739 e1Residuals[isNotNan], e2Residuals[isNotNan], 

740 ra[isNotNan], dec[isNotNan], 

741 units="degree", 

742 ) 

743 

744 gatherE12Stat[TEX]["E1"] = output.metric_E1 

745 gatherE12Stat[TEX]["E2"] = output.metric_E2 

746 gatherE12Stat[TEX]["Ex"] = output.metric_Ex 

747 

748 # TE3 and TE4 loop over detector and then median on visit. 

749 

750 psfTEx = { 

751 "TE3": self.psfTE3, 

752 "TE4": self.psfTE4, 

753 } 

754 

755 gatherE34Stat = { 

756 "TE3": {"E1": [], "E2": [], "Ex": [], }, 

757 "TE4": {"E1": [], "E2": [], "Ex": [], }, 

758 } 

759 # calibrateImage run at detector level, 

760 # need to wait second run of PSF to run this. 

761 if "detector" in psf_cat.colnames: 

762 detectorIds = list(set(psf_cat["detector"])) 

763 for TEX in ["TE3", "TE4"]: 

764 for ccdId in detectorIds: 

765 isccdId = (ccdId == psf_cat["detector"]) 

766 mask = (isccdId & isNotNan) 

767 if np.sum(mask) >= 2: 

768 output = psfTEx[TEX].run( 

769 e1Residuals[mask], e2Residuals[mask], 

770 ra[mask], dec[mask], 

771 units="degree", 

772 ) 

773 gatherE34Stat[TEX]["E1"].append(output.metric_E1) 

774 gatherE34Stat[TEX]["E2"].append(output.metric_E2) 

775 gatherE34Stat[TEX]["Ex"].append(output.metric_Ex) 

776 

777 self.psfTE1e1 = summary.psfTE1e1 = gatherE12Stat["TE1"]["E1"] 

778 self.psfTE1e2 = summary.psfTE1e2 = gatherE12Stat["TE1"]["E2"] 

779 self.psfTE1ex = summary.psfTE1ex = gatherE12Stat["TE1"]["Ex"] 

780 self.psfTE2e1 = summary.psfTE2e1 = gatherE12Stat["TE2"]["E1"] 

781 self.psfTE2e2 = summary.psfTE2e2 = gatherE12Stat["TE2"]["E2"] 

782 self.psfTE2ex = summary.psfTE2ex = gatherE12Stat["TE2"]["Ex"] 

783 self.psfTE3e1 = summary.psfTE3e1 = np.nanmedian(gatherE34Stat["TE3"]["E1"]) 

784 self.psfTE3e2 = summary.psfTE3e2 = np.nanmedian(gatherE34Stat["TE3"]["E2"]) 

785 self.psfTE3ex = summary.psfTE3ex = np.nanmedian(gatherE34Stat["TE3"]["Ex"]) 

786 self.psfTE4e1 = summary.psfTE4e1 = np.nanmedian(gatherE34Stat["TE4"]["E1"]) 

787 self.psfTE4e2 = summary.psfTE4e2 = np.nanmedian(gatherE34Stat["TE4"]["E2"]) 

788 self.psfTE4ex = summary.psfTE4ex = np.nanmedian(gatherE34Stat["TE4"]["Ex"]) 

789 

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

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

792 

793 Parameters 

794 ---------- 

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

796 Summary object to update in-place. 

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

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

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

800 bbox : `lsst.geom.Box2I` 

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

802 computed. 

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

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

805 the zenith distance. 

806 """ 

807 nan = float("nan") 

808 summary.raCorners = [nan]*4 

809 summary.decCorners = [nan]*4 

810 summary.ra = nan 

811 summary.dec = nan 

812 summary.pixelScale = nan 

813 summary.zenithDistance = nan 

814 

815 if wcs is None: 

816 return 

817 

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

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

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

821 

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

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

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

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

826 

827 date = visitInfo.getDate() 

828 

829 if date.isValid(): 829 ↛ exitline 829 didn't return from function 'update_wcs_stats' because the condition on line 829 was always true

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

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

832 # because the zenithDistance may vary significantly over a large 

833 # field of view. 

834 observatory = visitInfo.getObservatory() 

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

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

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

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

839 location=loc, format="mjd") 

840 coord = SkyCoord( 

841 summary.ra*units.degree, 

842 summary.dec*units.degree, 

843 obstime=obstime, 

844 location=loc, 

845 ) 

846 with warnings.catch_warnings(): 

847 warnings.simplefilter("ignore") 

848 altaz = coord.transform_to(AltAz) 

849 

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

851 

852 def update_photo_calib_stats(self, summary, photo_calib): 

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

854 calibration model. 

855 

856 Parameters 

857 ---------- 

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

859 Summary object to update in-place. 

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

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

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

863 """ 

864 if photo_calib is not None: 

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

866 else: 

867 summary.zeroPoint = float("nan") 

868 

869 def update_background_stats(self, summary, background): 

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

871 background model. 

872 

873 Parameters 

874 ---------- 

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

876 Summary object to update in-place. 

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

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

879 background will be reset (generally to NaN). 

880 

881 Notes 

882 ----- 

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

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

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

886 as well. 

887 """ 

888 if background is not None: 888 ↛ 893line 888 didn't jump to line 893 because the condition on line 888 was always true

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

890 for bg in background) 

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

892 else: 

893 summary.skyBg = float("nan") 

894 

895 def update_masked_image_stats(self, summary, masked_image): 

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

897 itself. 

898 

899 Parameters 

900 ---------- 

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

902 Summary object to update in-place. 

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

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

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

906 """ 

907 nan = float("nan") 

908 if masked_image is None: 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true

909 summary.skyNoise = nan 

910 summary.meanVar = nan 

911 return 

912 statsCtrl = afwMath.StatisticsControl() 

913 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

914 statsCtrl.setNumIter(self.config.clipIter) 

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

916 statsCtrl.setNanSafe(True) 

917 

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

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

920 summary.skyNoise = skyNoise 

921 

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

923 statsCtrl) 

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

925 summary.meanVar = meanVar 

926 

927 def update_effective_time_stats(self, summary, exposure): 

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

929 

930 The effective exposure time is the equivalent shutter open 

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

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

933 the observation of interest. This metric combines measurements 

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

935 transparency. It assumes that the observation is 

936 sky-background dominated. 

937 

938 .. _teff_definitions: 

939 

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

941 

942 References 

943 ---------- 

944 

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

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

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

948 

949 Parameters 

950 ---------- 

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

952 Summary object to update in-place. 

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

954 Exposure to grab band and exposure time metadata 

955 

956 """ 

957 nan = float("nan") 

958 summary.effTime = nan 

959 summary.effTimePsfSigmaScale = nan 

960 summary.effTimeSkyBgScale = nan 

961 summary.effTimeZeroPointScale = nan 

962 

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

964 filterLabel = exposure.getFilter() 

965 if (filterLabel is None) or (not filterLabel.hasBandLabel): 965 ↛ 966line 965 didn't jump to line 966 because the condition on line 965 was never true

966 band = None 

967 else: 

968 band = filterLabel.bandLabel 

969 

970 if band is None: 970 ↛ 971line 970 didn't jump to line 971 because the condition on line 970 was never true

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

972 return 

973 

974 # PSF component 

975 if np.isnan(summary.psfSigma): 975 ↛ 976line 975 didn't jump to line 976 because the condition on line 975 was never true

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

977 f_eff = nan 

978 elif band not in self.config.fiducialPsfSigma: 

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

980 f_eff = nan 

981 else: 

982 fiducialPsfSigma = self.config.fiducialPsfSigma[band] 

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

984 

985 # Transparency component 

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

987 if np.isnan(summary.zeroPoint): 

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

989 c_eff = nan 

990 elif band not in self.config.fiducialZeroPoint: 

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

992 c_eff = nan 

993 else: 

994 fiducialZeroPoint = self.config.fiducialZeroPoint[band] 

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

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

997 

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

999 if np.isnan(summary.skyBg): 999 ↛ 1000line 999 didn't jump to line 1000 because the condition on line 999 was never true

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

1001 b_eff = nan 

1002 elif band not in self.config.fiducialSkyBackground: 

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

1004 b_eff = nan 

1005 else: 

1006 fiducialSkyBackground = self.config.fiducialSkyBackground[band] 

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

1008 

1009 # Old effective exposure time scale factor 

1010 # t_eff = f_eff * c_eff * b_eff 

1011 

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

1013 if band not in self.config.fiducialMagLim: 

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

1015 effectiveTime = nan 

1016 elif band not in self.config.fiducialExpTime: 1016 ↛ 1017line 1016 didn't jump to line 1017 because the condition on line 1016 was never true

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

1018 effectiveTime = nan 

1019 else: 

1020 effectiveTime = compute_effective_time(summary.magLim, 

1021 self.config.fiducialMagLim[band], 

1022 self.config.fiducialExpTime[band]) 

1023 

1024 # Output quantities 

1025 summary.effTime = float(effectiveTime) 

1026 summary.effTimePsfSigmaScale = float(f_eff) 

1027 summary.effTimeSkyBgScale = float(b_eff) 

1028 summary.effTimeZeroPointScale = float(c_eff) 

1029 

1030 def update_magnitude_limit_stats(self, summary, exposure): 

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

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

1033 

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

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

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

1037 

1038 References 

1039 ---------- 

1040 

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

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

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

1044 

1045 

1046 Parameters 

1047 ---------- 

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

1049 Summary object to update in-place. 

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

1051 Exposure to grab band and exposure time metadata 

1052 """ 

1053 if exposure.getDetector() is None: 1053 ↛ 1054line 1053 didn't jump to line 1054 because the condition on line 1053 was never true

1054 summary.magLim = float("nan") 

1055 return 

1056 

1057 # Calculate the average readnoise [e-] 

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

1059 readNoise = np.nanmean(readNoiseList) 

1060 if np.isnan(readNoise): 1060 ↛ 1061line 1060 didn't jump to line 1061 because the condition on line 1060 was never true

1061 readNoise = 0.0 

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

1063 

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

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

1066 gain = np.nanmean(gainList) 

1067 if np.isnan(gain): 1067 ↛ 1068line 1067 didn't jump to line 1068 because the condition on line 1067 was never true

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

1069 summary.magLim = float("nan") 

1070 return 

1071 

1072 # Get the image units (default to "adu" if metadata key absent) 

1073 md = exposure.getMetadata() 

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

1075 gain = 1.0 

1076 

1077 # Convert readNoise to image units 

1078 readNoise /= gain 

1079 

1080 # Calculate the limiting magnitude. 

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

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

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

1084 summary.zeroPoint, readNoise, 

1085 gain, self.config.magLimSnr) 

1086 

1087 summary.magLim = float(magLim) 

1088 

1089 

1090def maximum_nearest_psf_distance( 

1091 image_mask, 

1092 psf_cat, 

1093 sampling=8, 

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

1095): 

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

1097 

1098 Parameters 

1099 ---------- 

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

1101 The mask plane associated with the exposure. 

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

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

1104 sampling : `int` 

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

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

1107 between adequate sampling versus speed. 

1108 bad_mask_bits : `list` [`str`] 

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

1110 "unmasked". 

1111 

1112 Returns 

1113 ------- 

1114 max_dist_to_nearest_psf : `float` 

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

1116 PSF model star. 

1117 """ 

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

1119 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

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

1121 

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

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

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

1125 

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

1127 for psf in psf_cat: 

1128 x_psf = psf["slot_Centroid_x"] 

1129 y_psf = psf["slot_Centroid_y"] 

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

1131 unmasked_dists = dist_to_nearest_psf * good 

1132 max_dist_to_nearest_psf = np.max(unmasked_dists) 

1133 

1134 return max_dist_to_nearest_psf 

1135 

1136 

1137def compute_psf_image_deltas( 

1138 image_mask, 

1139 image_psf, 

1140 sampling=96, 

1141 ap_radius_pix=3.0, 

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

1143): 

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

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

1146 image. 

1147 

1148 Parameters 

1149 ---------- 

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

1151 The mask plane associated with the exposure. 

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

1153 The PSF model associated with the exposure. 

1154 sampling : `int`, optional 

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

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

1157 adequate sampling versus speed. 

1158 ap_radius_pix : `float`, optional 

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

1160 PSF model. 

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

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

1163 "unmasked". 

1164 

1165 Returns 

1166 ------- 

1167 psf_trace_radius_delta, psf_ap_flux_delta : `float` 

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

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

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

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

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

1173 evaluate to NaN, then NaNs are returned immediately. 

1174 """ 

1175 psf_trace_radius_list = [] 

1176 psf_ap_flux_list = [] 

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

1178 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

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

1180 

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

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

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

1184 

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

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

1187 if is_good: 

1188 position = geom.Point2D(x_point, y_point) 

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

1190 psf_ap_flux = image_psf.computeApertureFlux(ap_radius_pix, position) 

1191 if ~np.isfinite(psf_trace_radius) and ~np.isfinite(psf_ap_flux): 1191 ↛ 1192line 1191 didn't jump to line 1192 because the condition on line 1191 was never true

1192 return float("nan"), float("nan") 

1193 psf_trace_radius_list.append(psf_trace_radius) 

1194 psf_ap_flux_list.append(psf_ap_flux) 

1195 

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

1197 if np.any(np.asarray(psf_ap_flux_list) < 0.0): # Consider any -ve flux entry as "bad". 1197 ↛ 1198line 1197 didn't jump to line 1198 because the condition on line 1197 was never true

1198 psf_ap_flux_delta = float("nan") 

1199 else: 

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

1201 

1202 return psf_trace_radius_delta, psf_ap_flux_delta 

1203 

1204 

1205def compute_ap_corr_sigma_scaled_delta( 

1206 image_mask, 

1207 image_ap_corr_field, 

1208 psfSigma, 

1209 sampling=96, 

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

1211): 

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

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

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

1215 unmasked region of the image. 

1216 

1217 Parameters 

1218 ---------- 

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

1220 The mask plane associated with the exposure. 

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

1222 The ChebyshevBoundedField representation of the aperture correction 

1223 of interest for the exposure. 

1224 psfSigma : `float` 

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

1226 in pixels. 

1227 sampling : `int`, optional 

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

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

1230 adequate sampling versus speed. 

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

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

1233 "unmasked". 

1234 

1235 Returns 

1236 ------- 

1237 ap_corr_sigma_scaled_delta : `float` 

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

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

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

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

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

1243 """ 

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

1245 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

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

1247 

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

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

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

1251 

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

1253 ap_corr_good = ap_corr[good] 

1254 if ~np.isfinite(ap_corr_good).all(): 1254 ↛ 1255line 1254 didn't jump to line 1255 because the condition on line 1254 was never true

1255 ap_corr_sigma_scaled_delta = float("nan") 

1256 else: 

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

1258 

1259 return ap_corr_sigma_scaled_delta 

1260 

1261 

1262def compute_magnitude_limit( 

1263 psfArea, 

1264 skyBg, 

1265 zeroPoint, 

1266 readNoise, 

1267 gain, 

1268 snr 

1269): 

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

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

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

1273 more details on the calculation). 

1274 

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

1276 

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

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

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

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

1281 should all consistently be in electrons or ADU. 

1282 

1283 Parameters 

1284 ---------- 

1285 psfArea : `float` 

1286 The effective area of the PSF [pix]. 

1287 skyBg : `float` 

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

1289 zeroPoint : `float` 

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

1291 readNoise : `float` 

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

1293 gain : `float` 

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

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

1296 snr : `float` 

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

1298 

1299 Returns 

1300 ------- 

1301 magnitude_limit : `float` 

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

1303 """ 

1304 # Effective number of pixels within the PSF calculated directly 

1305 # from the PSF model. 

1306 neff = psfArea 

1307 

1308 # Instrumental noise (read noise only) 

1309 sigma_inst = readNoise 

1310 

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

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

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

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

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

1316 

1317 # Convert to a magnitude using the zeropoint. 

1318 # Note: Zeropoint currently includes exposure time 

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

1320 

1321 return magnitude_limit 

1322 

1323 

1324def psf_sigma_to_psf_area(psfSigma, pixelScale): 

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

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

1327 

1328 Parameters 

1329 ---------- 

1330 psfSigma : `float` 

1331 The PSF sigma value [pix]. 

1332 pixelScale : `float` 

1333 The pixel scale [arcsec/pix]. 

1334 

1335 Returns 

1336 ------- 

1337 psf_area : `float` 

1338 Approximation of the PSF area [pix^2] 

1339 """ 

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

1341 fwhm_geom = psfSigma * 2.355 * pixelScale 

1342 fwhm_eff = (fwhm_geom - 0.052) / 0.822 

1343 # Area prefactor comes from LSE-40 

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

1345 return psf_area 

1346 

1347 

1348def compute_effective_time( 

1349 magLim, 

1350 fiducialMagLim, 

1351 fiducialExpTime 

1352): 

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

1354 

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

1356 

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

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

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

1360 

1361 Parameters 

1362 ---------- 

1363 magLim : `float` 

1364 The measured magnitude limit [mag]. 

1365 fiducialMagLim : `float` 

1366 The fiducial magnitude limit [mag]. 

1367 fiducialExpTime : `float` 

1368 The fiducial exposure time [s]. 

1369 

1370 Returns 

1371 ------- 

1372 effectiveTime : `float` 

1373 The effective exposure time. 

1374 """ 

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

1376 return effectiveTime