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

412 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 08: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.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 starHigherOrderMomentBase = pexConfig.Field( 

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

80 dtype=str, 

81 default="ext_shapeHSM_HigherOrderMomentsSource", 

82 ) 

83 psfSampling = pexConfig.Field( 

84 dtype=int, 

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

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

87 default=8, 

88 ) 

89 psfGridSampling = pexConfig.Field( 

90 dtype=int, 

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

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

93 default=96, 

94 ) 

95 minPsfApRadiusPix = pexConfig.Field( 

96 dtype=float, 

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

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

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

100 default=2.0, 

101 ) 

102 psfApCorrFieldName = pexConfig.Field( 

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

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

105 dtype=str, 

106 default="base_PsfFlux_instFlux" 

107 ) 

108 psfBadMaskPlanes = pexConfig.ListField( 

109 dtype=str, 

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

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

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

113 ) 

114 fiducialSkyBackground = pexConfig.DictField( 

115 keytype=str, 

116 itemtype=float, 

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

118 "Keyed by band.", 

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

120 ) 

121 fiducialPsfSigma = pexConfig.DictField( 

122 keytype=str, 

123 itemtype=float, 

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

125 "Keyed by band.", 

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

127 ) 

128 fiducialZeroPoint = pexConfig.DictField( 

129 keytype=str, 

130 itemtype=float, 

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

132 "Keyed by band.", 

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

134 ) 

135 fiducialReadNoise = pexConfig.DictField( 

136 keytype=str, 

137 itemtype=float, 

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

139 "Keyed by band.", 

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

141 ) 

142 fiducialExpTime = pexConfig.DictField( 

143 keytype=str, 

144 itemtype=float, 

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

146 "Keyed by band.", 

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

148 ) 

149 fiducialMagLim = pexConfig.DictField( 

150 keytype=str, 

151 itemtype=float, 

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

153 "Keyed by band.", 

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

155 ) 

156 maxEffectiveTransparency = pexConfig.Field( 

157 dtype=float, 

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

159 default=float("inf") 

160 ) 

161 magLimSnr = pexConfig.Field( 

162 dtype=float, 

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

164 default=5.0 

165 ) 

166 

167 def setDefaults(self): 

168 super().setDefaults() 

169 

170 self.starSelector.setDefaults() 

171 self.starSelector.doFlags = True 

172 self.starSelector.doSignalToNoise = True 

173 self.starSelector.doUnresolved = False 

174 self.starSelector.doIsolated = False 

175 self.starSelector.doRequireFiniteRaDec = False 

176 self.starSelector.doRequirePrimary = False 

177 

178 self.starSelector.signalToNoise.minimum = 50.0 

179 self.starSelector.signalToNoise.maximum = 1000.0 

180 

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

182 # Select stars used for PSF modeling. 

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

184 

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

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

187 

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

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

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

191 

192 Parameters 

193 ---------- 

194 band : `str` 

195 The band of interest 

196 pixelScale : `float` 

197 The pixel scale [arcsec/pix] 

198 gain : `float` 

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

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

201 

202 Returns 

203 ------- 

204 magnitude_limit : `float` 

205 The fiducial magnitude limit calculated from fiducial values. 

206 """ 

207 nan = float("nan") 

208 

209 # Fiducials from config 

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

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

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

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

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

215 magLimSnr = self.magLimSnr 

216 

217 # Derived fiducial quantities 

218 fiducialPsfArea = psf_sigma_to_psf_area(fiducialPsfSigma, pixelScale) 

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

220 fiducialSkyBg = fiducialSkyBackground * fiducialExpTime 

221 fiducialReadNoise /= gain 

222 

223 # Calculate the fiducial magnitude limit 

224 fiducialMagLim = compute_magnitude_limit(fiducialPsfArea, 

225 fiducialSkyBg, 

226 fiducialZeroPoint, 

227 fiducialReadNoise, 

228 gain, 

229 magLimSnr) 

230 

231 return fiducialMagLim 

232 

233 

234class ComputeExposureSummaryStatsTask(pipeBase.Task): 

235 """Task to compute exposure summary statistics. 

236 

237 This task computes various quantities suitable for DPDD and other 

238 downstream processing at the detector centers, including: 

239 - expTime 

240 - psfSigma 

241 - psfArea 

242 - psfIxx 

243 - psfIyy 

244 - psfIxy 

245 - ra 

246 - dec 

247 - pixelScale (arcsec/pixel) 

248 - zenithDistance 

249 - zeroPoint 

250 - skyBg 

251 - skyNoise 

252 - meanVar 

253 - raCorners 

254 - decCorners 

255 - astromOffsetMean 

256 - astromOffsetStd 

257 

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

259 - psfStarDeltaE1Median 

260 - psfStarDeltaE2Median 

261 - psfStarDeltaE1Scatter 

262 - psfStarDeltaE2Scatter 

263 - psfStarDeltaSizeMedian 

264 - psfStarDeltaSizeScatter 

265 - psfStarScaledDeltaSizeScatter 

266 

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

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

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

270 - maxDistToNearestPsf 

271 - psfTraceRadiusDelta 

272 - psfApFluxDelta 

273 

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

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

276 corrections across a given detector: 

277 - psfApCorrSigmaScaledDelta 

278 

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

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

281 measure of the deviation from a circular shape): 

282 - starEMedian 

283 - starUnNormalizedEMedian 

284 

285 These quantities are computed to assess depth: 

286 - effTime 

287 - effTimePsfSigmaScale 

288 - effTimeSkyBgScale 

289 - effTimeZeroPointScale 

290 - magLim 

291 """ 

292 ConfigClass = ComputeExposureSummaryStatsConfig 

293 _DefaultName = "computeExposureSummaryStats" 

294 

295 def __init__(self, **kwargs): 

296 super().__init__(**kwargs) 

297 

298 self.makeSubtask("starSelector") 

299 

300 @timeMethod 

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

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

303 background. 

304 

305 Parameters 

306 ---------- 

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

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

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

310 

311 Returns 

312 ------- 

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

314 """ 

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

316 

317 summary = afwImage.ExposureSummaryStats() 

318 

319 # Set exposure time. 

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

321 summary.expTime = exposureTime 

322 

323 bbox = exposure.getBBox() 

324 

325 psf = exposure.getPsf() 

326 self.update_psf_stats( 

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

328 ) 

329 

330 wcs = exposure.getWcs() 

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

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

333 

334 photoCalib = exposure.getPhotoCalib() 

335 self.update_photo_calib_stats(summary, photoCalib) 

336 

337 self.update_background_stats(summary, background) 

338 

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

340 

341 self.update_magnitude_limit_stats(summary, exposure) 

342 

343 self.update_effective_time_stats(summary, exposure) 

344 

345 md = exposure.getMetadata() 

346 if "SFM_ASTROM_OFFSET_MEAN" in md: 

347 summary.astromOffsetMean = md["SFM_ASTROM_OFFSET_MEAN"] 

348 summary.astromOffsetStd = md["SFM_ASTROM_OFFSET_STD"] 

349 

350 return summary 

351 

352 def update_psf_stats( 

353 self, 

354 summary, 

355 psf, 

356 bbox, 

357 sources=None, 

358 image_mask=None, 

359 image_ap_corr_map=None, 

360 sources_is_astropy=False, 

361 ): 

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

363 

364 Parameters 

365 ---------- 

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

367 Summary object to update in-place. 

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

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

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

371 bbox : `lsst.geom.Box2I` 

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

373 computed. 

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

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

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

377 The type of this table must correspond to the 

378 ``sources_is_astropy`` argument. 

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

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

381 metrics. 

382 sources_is_astropy : `bool`, optional 

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

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

385 latter). 

386 """ 

387 nan = float("nan") 

388 summary.psfSigma = nan 

389 summary.psfIxx = nan 

390 summary.psfIyy = nan 

391 summary.psfIxy = nan 

392 summary.psfArea = nan 

393 summary.nPsfStar = 0 

394 summary.psfStarDeltaE1Median = nan 

395 summary.psfStarDeltaE2Median = nan 

396 summary.psfStarDeltaE1Scatter = nan 

397 summary.psfStarDeltaE2Scatter = nan 

398 summary.psfStarDeltaSizeMedian = nan 

399 summary.psfStarDeltaSizeScatter = nan 

400 summary.psfStarScaledDeltaSizeScatter = nan 

401 summary.maxDistToNearestPsf = nan 

402 summary.psfTraceRadiusDelta = nan 

403 summary.psfApFluxDelta = nan 

404 summary.psfApCorrSigmaScaledDelta = nan 

405 summary.starEMedian = nan 

406 summary.starUnNormalizedEMedian = nan 

407 summary.starComa1Median = nan 

408 summary.starComa2Median = nan 

409 summary.starTrefoil1Median = nan 

410 summary.starTrefoil2Median = nan 

411 summary.starKurtosisMedian = nan 

412 summary.starE41Median = nan 

413 summary.starE42Median = nan 

414 

415 if psf is None: 

416 return 

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

418 summary.psfSigma = shape.getDeterminantRadius() 

419 summary.psfIxx = shape.getIxx() 

420 summary.psfIyy = shape.getIyy() 

421 summary.psfIxy = shape.getIxy() 

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

423 # The calculation of effective psf area is taken from 

424 # ls.st/srd Equation 1. 

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

426 

427 if image_mask is not None: 

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

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

430 psfTraceRadiusDelta, psfApFluxDelta = compute_psf_image_deltas( 

431 image_mask, 

432 psf, 

433 sampling=self.config.psfGridSampling, 

434 ap_radius_pix=psfApRadius, 

435 bad_mask_bits=self.config.psfBadMaskPlanes 

436 ) 

437 summary.psfTraceRadiusDelta = float(psfTraceRadiusDelta) 

438 summary.psfApFluxDelta = float(psfApFluxDelta) 

439 if image_ap_corr_map is not None: 

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

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

442 "image_ap_corr_map. Setting psfApCorrSigmaScaledDelta to NaN.") 

443 psfApCorrSigmaScaledDelta = nan 

444 else: 

445 image_ap_corr_field = image_ap_corr_map[self.config.psfApCorrFieldName] 

446 psfApCorrSigmaScaledDelta = compute_ap_corr_sigma_scaled_delta( 

447 image_mask, 

448 image_ap_corr_field, 

449 summary.psfSigma, 

450 sampling=self.config.psfGridSampling, 

451 bad_mask_bits=self.config.psfBadMaskPlanes, 

452 ) 

453 summary.psfApCorrSigmaScaledDelta = float(psfApCorrSigmaScaledDelta) 

454 

455 if sources is None: 

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

457 # the selection criteria in finalizeCharacterization lead to no 

458 # good sources). 

459 return 

460 

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

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

463 summary.nPsfStar = int(nPsfStar) 

464 

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

466 nPsfStarsUsedInStats = psf_mask.sum() 

467 

468 if nPsfStarsUsedInStats == 0: 

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

470 # of 0 stars and NaN values. 

471 return 

472 

473 if sources_is_astropy: 

474 psf_cat = sources[psf_mask] 

475 else: 

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

477 

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

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

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

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

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

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

484 

485 # Use the trace radius for the star size. 

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

487 

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

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

490 starSizeMedian = np.median(starSize) 

491 

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

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

494 starEMedian = np.median(starE) 

495 starUnNormalizedEMedian = np.median(starUnNormalizedE) 

496 summary.starEMedian = float(starEMedian) 

497 summary.starUnNormalizedEMedian = float(starUnNormalizedEMedian) 

498 

499 # Use the trace radius for the psf size. 

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

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

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

503 

504 psfStarDeltaE1Median = np.median(starE1 - psfE1) 

505 psfStarDeltaE1Scatter = sigmaMad(starE1 - psfE1, scale="normal") 

506 psfStarDeltaE2Median = np.median(starE2 - psfE2) 

507 psfStarDeltaE2Scatter = sigmaMad(starE2 - psfE2, scale="normal") 

508 

509 psfStarDeltaSizeMedian = np.median(starSize - psfSize) 

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

511 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian 

512 

513 summary.psfStarDeltaE1Median = float(psfStarDeltaE1Median) 

514 summary.psfStarDeltaE2Median = float(psfStarDeltaE2Median) 

515 summary.psfStarDeltaE1Scatter = float(psfStarDeltaE1Scatter) 

516 summary.psfStarDeltaE2Scatter = float(psfStarDeltaE2Scatter) 

517 summary.psfStarDeltaSizeMedian = float(psfStarDeltaSizeMedian) 

518 summary.psfStarDeltaSizeScatter = float(psfStarDeltaSizeScatter) 

519 summary.psfStarScaledDeltaSizeScatter = float(psfStarScaledDeltaSizeScatter) 

520 

521 # Higher-order moments and derived metrics. 

522 if sources_is_astropy: 

523 columnNames = psf_cat.colnames 

524 else: 

525 columnNames = psf_cat.schema.getNames() 

526 if self.config.starHigherOrderMomentBase + "_03" in columnNames: 

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

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

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

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

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

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

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

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

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

536 

537 starComa1Median = np.nanmedian(starM30 + starM12) 

538 starComa2Median = np.nanmedian(starM21 + starM03) 

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

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

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

542 starE41Median = np.nanmedian(starM40 - starM04) 

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

544 

545 summary.starComa1Median = float(starComa1Median) 

546 summary.starComa2Median = float(starComa2Median) 

547 summary.starTrefoil1Median = float(starTrefoil1Median) 

548 summary.starTrefoil2Median = float(starTrefoil2Median) 

549 summary.starKurtosisMedian = float(starKurtosisMedian) 

550 summary.starE41Median = float(starE41Median) 

551 summary.starE42Median = float(starE42Median) 

552 else: 

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

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

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

556 

557 if image_mask is not None: 

558 maxDistToNearestPsf = maximum_nearest_psf_distance( 

559 image_mask, 

560 psf_cat, 

561 sampling=self.config.psfSampling, 

562 bad_mask_bits=self.config.psfBadMaskPlanes 

563 ) 

564 summary.maxDistToNearestPsf = float(maxDistToNearestPsf) 

565 

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

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

568 

569 Parameters 

570 ---------- 

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

572 Summary object to update in-place. 

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

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

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

576 bbox : `lsst.geom.Box2I` 

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

578 computed. 

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

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

581 the zenith distance. 

582 """ 

583 nan = float("nan") 

584 summary.raCorners = [nan]*4 

585 summary.decCorners = [nan]*4 

586 summary.ra = nan 

587 summary.dec = nan 

588 summary.pixelScale = nan 

589 summary.zenithDistance = nan 

590 

591 if wcs is None: 

592 return 

593 

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

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

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

597 

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

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

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

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

602 

603 date = visitInfo.getDate() 

604 

605 if date.isValid(): 

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

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

608 # because the zenithDistance may vary significantly over a large 

609 # field of view. 

610 observatory = visitInfo.getObservatory() 

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

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

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

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

615 location=loc, format="mjd") 

616 coord = SkyCoord( 

617 summary.ra*units.degree, 

618 summary.dec*units.degree, 

619 obstime=obstime, 

620 location=loc, 

621 ) 

622 with warnings.catch_warnings(): 

623 warnings.simplefilter("ignore") 

624 altaz = coord.transform_to(AltAz) 

625 

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

627 

628 def update_photo_calib_stats(self, summary, photo_calib): 

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

630 calibration model. 

631 

632 Parameters 

633 ---------- 

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

635 Summary object to update in-place. 

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

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

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

639 """ 

640 if photo_calib is not None: 

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

642 else: 

643 summary.zeroPoint = float("nan") 

644 

645 def update_background_stats(self, summary, background): 

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

647 background model. 

648 

649 Parameters 

650 ---------- 

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

652 Summary object to update in-place. 

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

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

655 background will be reset (generally to NaN). 

656 

657 Notes 

658 ----- 

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

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

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

662 as well. 

663 """ 

664 if background is not None: 

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

666 for bg in background) 

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

668 else: 

669 summary.skyBg = float("nan") 

670 

671 def update_masked_image_stats(self, summary, masked_image): 

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

673 itself. 

674 

675 Parameters 

676 ---------- 

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

678 Summary object to update in-place. 

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

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

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

682 """ 

683 nan = float("nan") 

684 if masked_image is None: 

685 summary.skyNoise = nan 

686 summary.meanVar = nan 

687 return 

688 statsCtrl = afwMath.StatisticsControl() 

689 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

690 statsCtrl.setNumIter(self.config.clipIter) 

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

692 statsCtrl.setNanSafe(True) 

693 

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

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

696 summary.skyNoise = skyNoise 

697 

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

699 statsCtrl) 

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

701 summary.meanVar = meanVar 

702 

703 def update_effective_time_stats(self, summary, exposure): 

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

705 

706 The effective exposure time is the equivalent shutter open 

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

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

709 the observation of interest. This metric combines measurements 

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

711 transparency. It assumes that the observation is 

712 sky-background dominated. 

713 

714 .. _teff_definitions: 

715 

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

717 

718 References 

719 ---------- 

720 

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

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

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

724 

725 Parameters 

726 ---------- 

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

728 Summary object to update in-place. 

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

730 Exposure to grab band and exposure time metadata 

731 

732 """ 

733 nan = float("nan") 

734 summary.effTime = nan 

735 summary.effTimePsfSigmaScale = nan 

736 summary.effTimeSkyBgScale = nan 

737 summary.effTimeZeroPointScale = nan 

738 

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

740 filterLabel = exposure.getFilter() 

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

742 band = None 

743 else: 

744 band = filterLabel.bandLabel 

745 

746 if band is None: 

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

748 return 

749 

750 # PSF component 

751 if np.isnan(summary.psfSigma): 

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

753 f_eff = nan 

754 elif band not in self.config.fiducialPsfSigma: 

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

756 f_eff = nan 

757 else: 

758 fiducialPsfSigma = self.config.fiducialPsfSigma[band] 

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

760 

761 # Transparency component 

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

763 if np.isnan(summary.zeroPoint): 

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

765 c_eff = nan 

766 elif band not in self.config.fiducialZeroPoint: 

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

768 c_eff = nan 

769 else: 

770 fiducialZeroPoint = self.config.fiducialZeroPoint[band] 

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

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

773 

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

775 if np.isnan(summary.skyBg): 

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

777 b_eff = nan 

778 elif band not in self.config.fiducialSkyBackground: 

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

780 b_eff = nan 

781 else: 

782 fiducialSkyBackground = self.config.fiducialSkyBackground[band] 

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

784 

785 # Old effective exposure time scale factor 

786 # t_eff = f_eff * c_eff * b_eff 

787 

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

789 if band not in self.config.fiducialMagLim: 

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

791 effectiveTime = nan 

792 elif band not in self.config.fiducialExpTime: 

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

794 effectiveTime = nan 

795 else: 

796 effectiveTime = compute_effective_time(summary.magLim, 

797 self.config.fiducialMagLim[band], 

798 self.config.fiducialExpTime[band]) 

799 

800 # Output quantities 

801 summary.effTime = float(effectiveTime) 

802 summary.effTimePsfSigmaScale = float(f_eff) 

803 summary.effTimeSkyBgScale = float(b_eff) 

804 summary.effTimeZeroPointScale = float(c_eff) 

805 

806 def update_magnitude_limit_stats(self, summary, exposure): 

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

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

809 

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

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

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

813 

814 References 

815 ---------- 

816 

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

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

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

820 

821 

822 Parameters 

823 ---------- 

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

825 Summary object to update in-place. 

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

827 Exposure to grab band and exposure time metadata 

828 """ 

829 if exposure.getDetector() is None: 

830 summary.magLim = float("nan") 

831 return 

832 

833 # Calculate the average readnoise [e-] 

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

835 readNoise = np.nanmean(readNoiseList) 

836 if np.isnan(readNoise): 

837 readNoise = 0.0 

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

839 

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

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

842 gain = np.nanmean(gainList) 

843 if np.isnan(gain): 

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

845 summary.magLim = float("nan") 

846 return 

847 

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

849 md = exposure.getMetadata() 

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

851 gain = 1.0 

852 

853 # Convert readNoise to image units 

854 readNoise /= gain 

855 

856 # Calculate the limiting magnitude. 

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

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

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

860 summary.zeroPoint, readNoise, 

861 gain, self.config.magLimSnr) 

862 

863 summary.magLim = float(magLim) 

864 

865 

866def maximum_nearest_psf_distance( 

867 image_mask, 

868 psf_cat, 

869 sampling=8, 

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

871): 

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

873 

874 Parameters 

875 ---------- 

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

877 The mask plane associated with the exposure. 

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

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

880 sampling : `int` 

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

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

883 between adequate sampling versus speed. 

884 bad_mask_bits : `list` [`str`] 

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

886 "unmasked". 

887 

888 Returns 

889 ------- 

890 max_dist_to_nearest_psf : `float` 

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

892 PSF model star. 

893 """ 

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

895 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

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

897 

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

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

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

901 

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

903 for psf in psf_cat: 

904 x_psf = psf["slot_Centroid_x"] 

905 y_psf = psf["slot_Centroid_y"] 

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

907 unmasked_dists = dist_to_nearest_psf * good 

908 max_dist_to_nearest_psf = np.max(unmasked_dists) 

909 

910 return max_dist_to_nearest_psf 

911 

912 

913def compute_psf_image_deltas( 

914 image_mask, 

915 image_psf, 

916 sampling=96, 

917 ap_radius_pix=3.0, 

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

919): 

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

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

922 image. 

923 

924 Parameters 

925 ---------- 

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

927 The mask plane associated with the exposure. 

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

929 The PSF model associated with the exposure. 

930 sampling : `int`, optional 

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

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

933 adequate sampling versus speed. 

934 ap_radius_pix : `float`, optional 

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

936 PSF model. 

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

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

939 "unmasked". 

940 

941 Returns 

942 ------- 

943 psf_trace_radius_delta, psf_ap_flux_delta : `float` 

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

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

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

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

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

949 evaluate to NaN, then NaNs are returned immediately. 

950 """ 

951 psf_trace_radius_list = [] 

952 psf_ap_flux_list = [] 

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

954 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

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

956 

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

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

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

960 

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

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

963 if is_good: 

964 position = geom.Point2D(x_point, y_point) 

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

966 psf_ap_flux = image_psf.computeApertureFlux(ap_radius_pix, position) 

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

968 return float("nan"), float("nan") 

969 psf_trace_radius_list.append(psf_trace_radius) 

970 psf_ap_flux_list.append(psf_ap_flux) 

971 

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

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

974 psf_ap_flux_delta = float("nan") 

975 else: 

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

977 

978 return psf_trace_radius_delta, psf_ap_flux_delta 

979 

980 

981def compute_ap_corr_sigma_scaled_delta( 

982 image_mask, 

983 image_ap_corr_field, 

984 psfSigma, 

985 sampling=96, 

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

987): 

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

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

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

991 unmasked region of the image. 

992 

993 Parameters 

994 ---------- 

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

996 The mask plane associated with the exposure. 

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

998 The ChebyshevBoundedField representation of the aperture correction 

999 of interest for the exposure. 

1000 psfSigma : `float` 

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

1002 in pixels. 

1003 sampling : `int`, optional 

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

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

1006 adequate sampling versus speed. 

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

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

1009 "unmasked". 

1010 

1011 Returns 

1012 ------- 

1013 ap_corr_sigma_scaled_delta : `float` 

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

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

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

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

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

1019 """ 

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

1021 bitmask = image_mask.getPlaneBitMask(bad_mask_bits) 

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

1023 

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

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

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

1027 

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

1029 ap_corr_good = ap_corr[good] 

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

1031 ap_corr_sigma_scaled_delta = float("nan") 

1032 else: 

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

1034 

1035 return ap_corr_sigma_scaled_delta 

1036 

1037 

1038def compute_magnitude_limit( 

1039 psfArea, 

1040 skyBg, 

1041 zeroPoint, 

1042 readNoise, 

1043 gain, 

1044 snr 

1045): 

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

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

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

1049 more details on the calculation). 

1050 

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

1052 

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

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

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

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

1057 should all consistently be in electrons or ADU. 

1058 

1059 Parameters 

1060 ---------- 

1061 psfArea : `float` 

1062 The effective area of the PSF [pix]. 

1063 skyBg : `float` 

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

1065 zeroPoint : `float` 

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

1067 readNoise : `float` 

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

1069 gain : `float` 

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

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

1072 snr : `float` 

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

1074 

1075 Returns 

1076 ------- 

1077 magnitude_limit : `float` 

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

1079 """ 

1080 # Effective number of pixels within the PSF calculated directly 

1081 # from the PSF model. 

1082 neff = psfArea 

1083 

1084 # Instrumental noise (read noise only) 

1085 sigma_inst = readNoise 

1086 

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

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

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

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

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

1092 

1093 # Convert to a magnitude using the zeropoint. 

1094 # Note: Zeropoint currently includes exposure time 

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

1096 

1097 return magnitude_limit 

1098 

1099 

1100def psf_sigma_to_psf_area(psfSigma, pixelScale): 

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

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

1103 

1104 Parameters 

1105 ---------- 

1106 psfSigma : `float` 

1107 The PSF sigma value [pix]. 

1108 pixelScale : `float` 

1109 The pixel scale [arcsec/pix]. 

1110 

1111 Returns 

1112 ------- 

1113 psf_area : `float` 

1114 Approximation of the PSF area [pix^2] 

1115 """ 

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

1117 fwhm_geom = psfSigma * 2.355 * pixelScale 

1118 fwhm_eff = (fwhm_geom - 0.052) / 0.822 

1119 # Area prefactor comes from LSE-40 

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

1121 return psf_area 

1122 

1123 

1124def compute_effective_time( 

1125 magLim, 

1126 fiducialMagLim, 

1127 fiducialExpTime 

1128): 

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

1130 

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

1132 

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

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

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

1136 

1137 Parameters 

1138 ---------- 

1139 magLim : `float` 

1140 The measured magnitude limit [mag]. 

1141 fiducialMagLim : `float` 

1142 The fiducial magnitude limit [mag]. 

1143 fiducialExpTime : `float` 

1144 The fiducial exposure time [s]. 

1145 

1146 Returns 

1147 ------- 

1148 effectiveTime : `float` 

1149 The effective exposure time. 

1150 """ 

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

1152 return effectiveTime