Coverage for python / lsst / pipe / tasks / computeExposureSummaryStats.py: 13%
376 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 08:38 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 08:38 +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/>.
22__all__ = ["ComputeExposureSummaryStatsTask", "ComputeExposureSummaryStatsConfig"]
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
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
42class ComputeExposureSummaryStatsConfig(pexConfig.Config):
43 """Config for ComputeExposureSummaryTask"""
44 sigmaClip = pexConfig.Field(
45 dtype=float,
46 doc="Sigma for outlier rejection for sky noise.",
47 default=3.0,
48 )
49 clipIter = pexConfig.Field(
50 dtype=int,
51 doc="Number of iterations of outlier rejection for sky noise.",
52 default=2,
53 )
54 badMaskPlanes = pexConfig.ListField(
55 dtype=str,
56 doc="Mask planes that, if set, the associated pixel should not be included sky noise calculation.",
57 default=("NO_DATA", "SUSPECT"),
58 )
59 starSelection = pexConfig.Field(
60 doc="Field to select full list of sources used for PSF modeling.",
61 dtype=str,
62 default="calib_psf_used",
63 )
64 starSelector = pexConfig.ConfigurableField(
65 target=ScienceSourceSelectorTask,
66 doc="Selection of sources to compute PSF star statistics.",
67 )
68 starShape = pexConfig.Field(
69 doc="Base name of columns to use for the source shape in the PSF statistics computation.",
70 dtype=str,
71 default="slot_Shape"
72 )
73 psfShape = pexConfig.Field(
74 doc="Base name of columns to use for the PSF shape in the PSF statistics computation.",
75 dtype=str,
76 default="slot_PsfShape"
77 )
78 psfSampling = pexConfig.Field(
79 dtype=int,
80 doc="Sampling rate in pixels in each dimension for the maxDistToNearestPsf metric "
81 "caclulation grid (the tradeoff is between adequate sampling versus speed).",
82 default=8,
83 )
84 psfGridSampling = pexConfig.Field(
85 dtype=int,
86 doc="Sampling rate in pixels in each dimension for PSF model robustness metric "
87 "caclulations grid (the tradeoff is between adequate sampling versus speed).",
88 default=96,
89 )
90 minPsfApRadiusPix = pexConfig.Field(
91 dtype=float,
92 doc="Minimum radius in pixels of the aperture within which to measure the flux of "
93 "the PSF model for the psfApFluxDelta metric calculation (the radius is computed as "
94 "max(``minPsfApRadius``, 3*psfSigma)).",
95 default=2.0,
96 )
97 psfApCorrFieldName = pexConfig.Field(
98 doc="Name of the flux column associated with the aperture correction of the PSF model "
99 "to use for the psfApCorrSigmaScaledDelta metric calculation.",
100 dtype=str,
101 default="base_PsfFlux_instFlux"
102 )
103 psfBadMaskPlanes = pexConfig.ListField(
104 dtype=str,
105 doc="Mask planes that, if set, the associated pixel should not be included in the PSF model "
106 "robutsness metric calculations (namely, maxDistToNearestPsf and psfTraceRadiusDelta).",
107 default=("BAD", "CR", "EDGE", "INTRP", "NO_DATA", "SAT", "SUSPECT"),
108 )
109 fiducialSkyBackground = pexConfig.DictField(
110 keytype=str,
111 itemtype=float,
112 doc="Fiducial sky background level (ADU/s) assumed when calculating effective exposure time. "
113 "Keyed by band.",
114 default={'u': 1.0, 'g': 1.0, 'r': 1.0, 'i': 1.0, 'z': 1.0, 'y': 1.0},
115 )
116 fiducialPsfSigma = pexConfig.DictField(
117 keytype=str,
118 itemtype=float,
119 doc="Fiducial PSF sigma (pixels) assumed when calculating effective exposure time. "
120 "Keyed by band.",
121 default={'u': 1.0, 'g': 1.0, 'r': 1.0, 'i': 1.0, 'z': 1.0, 'y': 1.0},
122 )
123 fiducialZeroPoint = pexConfig.DictField(
124 keytype=str,
125 itemtype=float,
126 doc="Fiducial zero point assumed when calculating effective exposure time. "
127 "Keyed by band.",
128 default={'u': 25.0, 'g': 25.0, 'r': 25.0, 'i': 25.0, 'z': 25.0, 'y': 25.0},
129 )
130 fiducialReadNoise = pexConfig.DictField(
131 keytype=str,
132 itemtype=float,
133 doc="Fiducial readnoise (electrons) assumed when calculating effective exposure time. "
134 "Keyed by band.",
135 default={'u': 9.0, 'g': 9.0, 'r': 9.0, 'i': 9.0, 'z': 9.0, 'y': 9.0},
136 )
137 fiducialExpTime = pexConfig.DictField(
138 keytype=str,
139 itemtype=float,
140 doc="Fiducial exposure time (seconds). "
141 "Keyed by band.",
142 default={'u': 30.0, 'g': 30.0, 'r': 30.0, 'i': 30.0, 'z': 30.0, 'y': 30.0},
143 )
144 fiducialMagLim = pexConfig.DictField(
145 keytype=str,
146 itemtype=float,
147 doc="Fiducial magnitude limit depth at SNR=5. "
148 "Keyed by band.",
149 default={'u': 25.0, 'g': 25.0, 'r': 25.0, 'i': 25.0, 'z': 25.0, 'y': 25.0},
150 )
151 maxEffectiveTransparency = pexConfig.Field(
152 dtype=float,
153 doc="Maximum value allowed for effective transparency scale factor (often inf or 1.0).",
154 default=float('inf')
155 )
156 magLimSnr = pexConfig.Field(
157 dtype=float,
158 doc="Signal-to-noise ratio for computing the magnitude limit depth.",
159 default=5.0
160 )
162 def setDefaults(self):
163 super().setDefaults()
165 self.starSelector.setDefaults()
166 self.starSelector.doFlags = True
167 self.starSelector.doSignalToNoise = True
168 self.starSelector.doUnresolved = False
169 self.starSelector.doIsolated = False
170 self.starSelector.doRequireFiniteRaDec = False
171 self.starSelector.doRequirePrimary = False
173 self.starSelector.signalToNoise.minimum = 50.0
174 self.starSelector.signalToNoise.maximum = 1000.0
176 self.starSelector.flags.bad = ["slot_Shape_flag", "slot_PsfFlux_flag"]
177 # Select stars used for PSF modeling.
178 self.starSelector.flags.good = ["calib_psf_used"]
180 self.starSelector.signalToNoise.fluxField = "slot_PsfFlux_instFlux"
181 self.starSelector.signalToNoise.errField = "slot_PsfFlux_instFluxErr"
183 def fiducialMagnitudeLimit(self, band, pixelScale, gain):
184 """Compute the fiducial point-source magnitude limit based on config values.
185 This follows the conventions laid out in SMTN-002, LSE-40, and DMTN-296.
187 Parameters
188 ----------
189 band : `str`
190 The band of interest
191 pixelScale : `float`
192 The pixel scale [arcsec/pix]
193 gain : `float`
194 The instrumental gain for the exposure [e-/ADU]. The gain should
195 be 1.0 if the image units are [e-].
197 Returns
198 -------
199 magnitude_limit : `float`
200 The fiducial magnitude limit calculated from fiducial values.
201 """
202 nan = float("nan")
204 # Fiducials from config
205 fiducialPsfSigma = self.fiducialPsfSigma.get(band, nan)
206 fiducialSkyBackground = self.fiducialSkyBackground.get(band, nan)
207 fiducialZeroPoint = self.fiducialZeroPoint.get(band, nan)
208 fiducialReadNoise = self.fiducialReadNoise.get(band, nan)
209 fiducialExpTime = self.fiducialExpTime.get(band, nan)
210 magLimSnr = self.magLimSnr
212 # Derived fiducial quantities
213 fiducialPsfArea = psf_sigma_to_psf_area(fiducialPsfSigma, pixelScale)
214 fiducialZeroPoint += 2.5*np.log10(fiducialExpTime)
215 fiducialSkyBg = fiducialSkyBackground * fiducialExpTime
216 fiducialReadNoise /= gain
218 # Calculate the fiducial magnitude limit
219 fiducialMagLim = compute_magnitude_limit(fiducialPsfArea,
220 fiducialSkyBg,
221 fiducialZeroPoint,
222 fiducialReadNoise,
223 gain,
224 magLimSnr)
226 return fiducialMagLim
229class ComputeExposureSummaryStatsTask(pipeBase.Task):
230 """Task to compute exposure summary statistics.
232 This task computes various quantities suitable for DPDD and other
233 downstream processing at the detector centers, including:
234 - expTime
235 - psfSigma
236 - psfArea
237 - psfIxx
238 - psfIyy
239 - psfIxy
240 - ra
241 - dec
242 - pixelScale (arcsec/pixel)
243 - zenithDistance
244 - zeroPoint
245 - skyBg
246 - skyNoise
247 - meanVar
248 - raCorners
249 - decCorners
250 - astromOffsetMean
251 - astromOffsetStd
253 These additional quantities are computed from the stars in the detector:
254 - psfStarDeltaE1Median
255 - psfStarDeltaE2Median
256 - psfStarDeltaE1Scatter
257 - psfStarDeltaE2Scatter
258 - psfStarDeltaSizeMedian
259 - psfStarDeltaSizeScatter
260 - psfStarScaledDeltaSizeScatter
262 These quantities are computed based on the PSF model and image mask
263 to assess the robustness of the PSF model across a given detector
264 (against, e.g., extrapolation instability):
265 - maxDistToNearestPsf
266 - psfTraceRadiusDelta
267 - psfApFluxDelta
269 This quantity is computed based on the aperture correction map, the
270 psfSigma, and the image mask to assess the robustness of the aperture
271 corrections across a given detector:
272 - psfApCorrSigmaScaledDelta
274 These quantities are computed based on the shape measurements of the
275 sources used in the PSF model to assess the image quality (i.e. as a
276 measure of the deviation from a circular shape):
277 - starEMedian
278 - starUnNormalizedEMedian
280 These quantities are computed to assess depth:
281 - effTime
282 - effTimePsfSigmaScale
283 - effTimeSkyBgScale
284 - effTimeZeroPointScale
285 - magLim
286 """
287 ConfigClass = ComputeExposureSummaryStatsConfig
288 _DefaultName = "computeExposureSummaryStats"
290 def __init__(self, **kwargs):
291 super().__init__(**kwargs)
293 self.makeSubtask("starSelector")
295 @timeMethod
296 def run(self, exposure, sources, background):
297 """Measure exposure statistics from the exposure, sources, and
298 background.
300 Parameters
301 ----------
302 exposure : `lsst.afw.image.ExposureF`
303 sources : `lsst.afw.table.SourceCatalog`
304 background : `lsst.afw.math.BackgroundList`
306 Returns
307 -------
308 summary : `lsst.afw.image.ExposureSummary`
309 """
310 self.log.info("Measuring exposure statistics")
312 summary = afwImage.ExposureSummaryStats()
314 # Set exposure time.
315 exposureTime = exposure.getInfo().getVisitInfo().getExposureTime()
316 summary.expTime = exposureTime
318 bbox = exposure.getBBox()
320 psf = exposure.getPsf()
321 self.update_psf_stats(
322 summary, psf, bbox, sources, image_mask=exposure.mask, image_ap_corr_map=exposure.apCorrMap
323 )
325 wcs = exposure.getWcs()
326 visitInfo = exposure.getInfo().getVisitInfo()
327 self.update_wcs_stats(summary, wcs, bbox, visitInfo)
329 photoCalib = exposure.getPhotoCalib()
330 self.update_photo_calib_stats(summary, photoCalib)
332 self.update_background_stats(summary, background)
334 self.update_masked_image_stats(summary, exposure.getMaskedImage())
336 self.update_magnitude_limit_stats(summary, exposure)
338 self.update_effective_time_stats(summary, exposure)
340 md = exposure.getMetadata()
341 if 'SFM_ASTROM_OFFSET_MEAN' in md:
342 summary.astromOffsetMean = md['SFM_ASTROM_OFFSET_MEAN']
343 summary.astromOffsetStd = md['SFM_ASTROM_OFFSET_STD']
345 return summary
347 def update_psf_stats(
348 self,
349 summary,
350 psf,
351 bbox,
352 sources=None,
353 image_mask=None,
354 image_ap_corr_map=None,
355 sources_is_astropy=False,
356 ):
357 """Compute all summary-statistic fields that depend on the PSF model.
359 Parameters
360 ----------
361 summary : `lsst.afw.image.ExposureSummaryStats`
362 Summary object to update in-place.
363 psf : `lsst.afw.detection.Psf` or `None`
364 Point spread function model. If `None`, all fields that depend on
365 the PSF will be reset (generally to NaN).
366 bbox : `lsst.geom.Box2I`
367 Bounding box of the image for which summary stats are being
368 computed.
369 sources : `lsst.afw.table.SourceCatalog` or `astropy.table.Table`
370 Catalog for quantities that are computed from source table columns.
371 If `None`, these quantities will be reset (generally to NaN).
372 The type of this table must correspond to the
373 ``sources_is_astropy`` argument.
374 image_mask : `lsst.afw.image.Mask`, optional
375 Mask image that may be used to compute distance-to-nearest-star
376 metrics.
377 sources_is_astropy : `bool`, optional
378 Whether ``sources`` is an `astropy.table.Table` instance instead
379 of an `lsst.afw.table.Catalog` instance. Default is `False` (the
380 latter).
381 """
382 nan = float("nan")
383 summary.psfSigma = nan
384 summary.psfIxx = nan
385 summary.psfIyy = nan
386 summary.psfIxy = nan
387 summary.psfArea = nan
388 summary.nPsfStar = 0
389 summary.psfStarDeltaE1Median = nan
390 summary.psfStarDeltaE2Median = nan
391 summary.psfStarDeltaE1Scatter = nan
392 summary.psfStarDeltaE2Scatter = nan
393 summary.psfStarDeltaSizeMedian = nan
394 summary.psfStarDeltaSizeScatter = nan
395 summary.psfStarScaledDeltaSizeScatter = nan
396 summary.maxDistToNearestPsf = nan
397 summary.psfTraceRadiusDelta = nan
398 summary.psfApFluxDelta = nan
399 summary.psfApCorrSigmaScaledDelta = nan
400 summary.starEMedian = nan
401 summary.starUnNormalizedEMedian = nan
403 if psf is None:
404 return
405 shape = psf.computeShape(bbox.getCenter())
406 summary.psfSigma = shape.getDeterminantRadius()
407 summary.psfIxx = shape.getIxx()
408 summary.psfIyy = shape.getIyy()
409 summary.psfIxy = shape.getIxy()
410 im = psf.computeKernelImage(bbox.getCenter())
411 # The calculation of effective psf area is taken from
412 # ls.st/srd Equation 1.
413 summary.psfArea = float(np.sum(im.array)**2./np.sum(im.array**2.))
415 if image_mask is not None:
416 psfApRadius = max(self.config.minPsfApRadiusPix, 3.0*summary.psfSigma)
417 self.log.debug("Using radius of %.3f (pixels) for psfApFluxDelta metric", psfApRadius)
418 psfTraceRadiusDelta, psfApFluxDelta = compute_psf_image_deltas(
419 image_mask,
420 psf,
421 sampling=self.config.psfGridSampling,
422 ap_radius_pix=psfApRadius,
423 bad_mask_bits=self.config.psfBadMaskPlanes
424 )
425 summary.psfTraceRadiusDelta = float(psfTraceRadiusDelta)
426 summary.psfApFluxDelta = float(psfApFluxDelta)
427 if image_ap_corr_map is not None:
428 if self.config.psfApCorrFieldName not in image_ap_corr_map.keys():
429 self.log.warning(f"{self.config.psfApCorrFieldName} not found in "
430 "image_ap_corr_map. Setting psfApCorrSigmaScaledDelta to NaN.")
431 psfApCorrSigmaScaledDelta = nan
432 else:
433 image_ap_corr_field = image_ap_corr_map[self.config.psfApCorrFieldName]
434 psfApCorrSigmaScaledDelta = compute_ap_corr_sigma_scaled_delta(
435 image_mask,
436 image_ap_corr_field,
437 summary.psfSigma,
438 sampling=self.config.psfGridSampling,
439 bad_mask_bits=self.config.psfBadMaskPlanes,
440 )
441 summary.psfApCorrSigmaScaledDelta = float(psfApCorrSigmaScaledDelta)
443 if sources is None:
444 # No sources are available (as in some tests and rare cases where
445 # the selection criteria in finalizeCharacterization lead to no
446 # good sources).
447 return
449 # Count the total number of psf stars used (prior to stats selection).
450 nPsfStar = sources[self.config.starSelection].sum()
451 summary.nPsfStar = int(nPsfStar)
453 psf_mask = self.starSelector.run(sources).selected
454 nPsfStarsUsedInStats = psf_mask.sum()
456 if nPsfStarsUsedInStats == 0:
457 # No stars to measure statistics, so we must return the defaults
458 # of 0 stars and NaN values.
459 return
461 if sources_is_astropy:
462 psf_cat = sources[psf_mask]
463 else:
464 psf_cat = sources[psf_mask].copy(deep=True)
466 starXX = psf_cat[self.config.starShape + '_xx']
467 starYY = psf_cat[self.config.starShape + '_yy']
468 starXY = psf_cat[self.config.starShape + '_xy']
469 psfXX = psf_cat[self.config.psfShape + '_xx']
470 psfYY = psf_cat[self.config.psfShape + '_yy']
471 psfXY = psf_cat[self.config.psfShape + '_xy']
473 # Use the trace radius for the star size.
474 starSize = np.sqrt(starXX/2. + starYY/2.)
476 starE1 = (starXX - starYY)/(starXX + starYY)
477 starE2 = 2*starXY/(starXX + starYY)
478 starSizeMedian = np.median(starSize)
480 starE = np.sqrt(starE1**2.0 + starE2**2.0)
481 starUnNormalizedE = np.sqrt((starXX - starYY)**2.0 + (2.0*starXY)**2.0)
482 starEMedian = np.median(starE)
483 starUnNormalizedEMedian = np.median(starUnNormalizedE)
484 summary.starEMedian = float(starEMedian)
485 summary.starUnNormalizedEMedian = float(starUnNormalizedEMedian)
487 # Use the trace radius for the psf size.
488 psfSize = np.sqrt(psfXX/2. + psfYY/2.)
489 psfE1 = (psfXX - psfYY)/(psfXX + psfYY)
490 psfE2 = 2*psfXY/(psfXX + psfYY)
492 psfStarDeltaE1Median = np.median(starE1 - psfE1)
493 psfStarDeltaE1Scatter = sigmaMad(starE1 - psfE1, scale='normal')
494 psfStarDeltaE2Median = np.median(starE2 - psfE2)
495 psfStarDeltaE2Scatter = sigmaMad(starE2 - psfE2, scale='normal')
497 psfStarDeltaSizeMedian = np.median(starSize - psfSize)
498 psfStarDeltaSizeScatter = sigmaMad(starSize - psfSize, scale='normal')
499 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian
501 summary.psfStarDeltaE1Median = float(psfStarDeltaE1Median)
502 summary.psfStarDeltaE2Median = float(psfStarDeltaE2Median)
503 summary.psfStarDeltaE1Scatter = float(psfStarDeltaE1Scatter)
504 summary.psfStarDeltaE2Scatter = float(psfStarDeltaE2Scatter)
505 summary.psfStarDeltaSizeMedian = float(psfStarDeltaSizeMedian)
506 summary.psfStarDeltaSizeScatter = float(psfStarDeltaSizeScatter)
507 summary.psfStarScaledDeltaSizeScatter = float(psfStarScaledDeltaSizeScatter)
509 if image_mask is not None:
510 maxDistToNearestPsf = maximum_nearest_psf_distance(
511 image_mask,
512 psf_cat,
513 sampling=self.config.psfSampling,
514 bad_mask_bits=self.config.psfBadMaskPlanes
515 )
516 summary.maxDistToNearestPsf = float(maxDistToNearestPsf)
518 def update_wcs_stats(self, summary, wcs, bbox, visitInfo):
519 """Compute all summary-statistic fields that depend on the WCS model.
521 Parameters
522 ----------
523 summary : `lsst.afw.image.ExposureSummaryStats`
524 Summary object to update in-place.
525 wcs : `lsst.afw.geom.SkyWcs` or `None`
526 Astrometric calibration model. If `None`, all fields that depend
527 on the WCS will be reset (generally to NaN).
528 bbox : `lsst.geom.Box2I`
529 Bounding box of the image for which summary stats are being
530 computed.
531 visitInfo : `lsst.afw.image.VisitInfo`
532 Observation information used in together with ``wcs`` to compute
533 the zenith distance.
534 """
535 nan = float("nan")
536 summary.raCorners = [nan]*4
537 summary.decCorners = [nan]*4
538 summary.ra = nan
539 summary.dec = nan
540 summary.pixelScale = nan
541 summary.zenithDistance = nan
543 if wcs is None:
544 return
546 sph_pts = wcs.pixelToSky(geom.Box2D(bbox).getCorners())
547 summary.raCorners = [float(sph.getRa().asDegrees()) for sph in sph_pts]
548 summary.decCorners = [float(sph.getDec().asDegrees()) for sph in sph_pts]
550 sph_pt = wcs.pixelToSky(bbox.getCenter())
551 summary.ra = sph_pt.getRa().asDegrees()
552 summary.dec = sph_pt.getDec().asDegrees()
553 summary.pixelScale = wcs.getPixelScale(bbox.getCenter()).asArcseconds()
555 date = visitInfo.getDate()
557 if date.isValid():
558 # We compute the zenithDistance at the center of the detector
559 # rather than use the boresight value available via the visitInfo,
560 # because the zenithDistance may vary significantly over a large
561 # field of view.
562 observatory = visitInfo.getObservatory()
563 loc = EarthLocation(lat=observatory.getLatitude().asDegrees()*units.deg,
564 lon=observatory.getLongitude().asDegrees()*units.deg,
565 height=observatory.getElevation()*units.m)
566 obstime = Time(visitInfo.getDate().get(system=DateTime.MJD),
567 location=loc, format='mjd')
568 coord = SkyCoord(
569 summary.ra*units.degree,
570 summary.dec*units.degree,
571 obstime=obstime,
572 location=loc,
573 )
574 with warnings.catch_warnings():
575 warnings.simplefilter('ignore')
576 altaz = coord.transform_to(AltAz)
578 summary.zenithDistance = float(90.0 - altaz.alt.degree)
580 def update_photo_calib_stats(self, summary, photo_calib):
581 """Compute all summary-statistic fields that depend on the photometric
582 calibration model.
584 Parameters
585 ----------
586 summary : `lsst.afw.image.ExposureSummaryStats`
587 Summary object to update in-place.
588 photo_calib : `lsst.afw.image.PhotoCalib` or `None`
589 Photometric calibration model. If `None`, all fields that depend
590 on the photometric calibration will be reset (generally to NaN).
591 """
592 if photo_calib is not None:
593 summary.zeroPoint = float(2.5*np.log10(photo_calib.getInstFluxAtZeroMagnitude()))
594 else:
595 summary.zeroPoint = float("nan")
597 def update_background_stats(self, summary, background):
598 """Compute summary-statistic fields that depend only on the
599 background model.
601 Parameters
602 ----------
603 summary : `lsst.afw.image.ExposureSummaryStats`
604 Summary object to update in-place.
605 background : `lsst.afw.math.BackgroundList` or `None`
606 Background model. If `None`, all fields that depend on the
607 background will be reset (generally to NaN).
609 Notes
610 -----
611 This does not include fields that depend on the background-subtracted
612 masked image; when the background changes, it should generally be
613 applied to the image and `update_masked_image_stats` should be called
614 as well.
615 """
616 if background is not None:
617 bgStats = (bg[0].getStatsImage().getImage().array
618 for bg in background)
619 summary.skyBg = float(sum(np.median(bg[np.isfinite(bg)]) for bg in bgStats))
620 else:
621 summary.skyBg = float("nan")
623 def update_masked_image_stats(self, summary, masked_image):
624 """Compute summary-statistic fields that depend on the masked image
625 itself.
627 Parameters
628 ----------
629 summary : `lsst.afw.image.ExposureSummaryStats`
630 Summary object to update in-place.
631 masked_image : `lsst.afw.image.MaskedImage` or `None`
632 Masked image. If `None`, all fields that depend
633 on the masked image will be reset (generally to NaN).
634 """
635 nan = float("nan")
636 if masked_image is None:
637 summary.skyNoise = nan
638 summary.meanVar = nan
639 return
640 statsCtrl = afwMath.StatisticsControl()
641 statsCtrl.setNumSigmaClip(self.config.sigmaClip)
642 statsCtrl.setNumIter(self.config.clipIter)
643 statsCtrl.setAndMask(afwImage.Mask.getPlaneBitMask(self.config.badMaskPlanes))
644 statsCtrl.setNanSafe(True)
646 statObj = afwMath.makeStatistics(masked_image, afwMath.STDEVCLIP, statsCtrl)
647 skyNoise, _ = statObj.getResult(afwMath.STDEVCLIP)
648 summary.skyNoise = skyNoise
650 statObj = afwMath.makeStatistics(masked_image.variance, masked_image.mask, afwMath.MEANCLIP,
651 statsCtrl)
652 meanVar, _ = statObj.getResult(afwMath.MEANCLIP)
653 summary.meanVar = meanVar
655 def update_effective_time_stats(self, summary, exposure):
656 """Compute effective exposure time statistics to estimate depth.
658 The effective exposure time is the equivalent shutter open
659 time that would be needed under nominal conditions to give the
660 same signal-to-noise for a point source as what is achieved by
661 the observation of interest. This metric combines measurements
662 of the point-spread function, the sky brightness, and the
663 transparency. It assumes that the observation is
664 sky-background dominated.
666 .. _teff_definitions:
668 The effective exposure time and its subcomponents are defined in [1]_.
670 References
671 ----------
673 .. [1] Neilsen, E.H., Bernstein, G., Gruendl, R., and Kent, S. (2016).
674 Limiting Magnitude, \tau, teff, and Image Quality in DES Year 1
675 https://www.osti.gov/biblio/1250877/
677 Parameters
678 ----------
679 summary : `lsst.afw.image.ExposureSummaryStats`
680 Summary object to update in-place.
681 exposure : `lsst.afw.image.ExposureF`
682 Exposure to grab band and exposure time metadata
684 """
685 nan = float("nan")
686 summary.effTime = nan
687 summary.effTimePsfSigmaScale = nan
688 summary.effTimeSkyBgScale = nan
689 summary.effTimeZeroPointScale = nan
691 exposureTime = exposure.getInfo().getVisitInfo().getExposureTime()
692 filterLabel = exposure.getFilter()
693 if (filterLabel is None) or (not filterLabel.hasBandLabel):
694 band = None
695 else:
696 band = filterLabel.bandLabel
698 if band is None:
699 self.log.warning("No band associated with exposure; effTime not calculated.")
700 return
702 # PSF component
703 if np.isnan(summary.psfSigma):
704 self.log.debug("PSF sigma is NaN")
705 f_eff = nan
706 elif band not in self.config.fiducialPsfSigma:
707 self.log.debug(f"Fiducial PSF value not found for {band}")
708 f_eff = nan
709 else:
710 fiducialPsfSigma = self.config.fiducialPsfSigma[band]
711 f_eff = (summary.psfSigma / fiducialPsfSigma)**-2
713 # Transparency component
714 # Note: Assumes that the zeropoint includes the exposure time
715 if np.isnan(summary.zeroPoint):
716 self.log.debug("Zero point is NaN")
717 c_eff = nan
718 elif band not in self.config.fiducialZeroPoint:
719 self.log.debug(f"Fiducial zero point value not found for {band}")
720 c_eff = nan
721 else:
722 fiducialZeroPoint = self.config.fiducialZeroPoint[band]
723 zeroPointDiff = fiducialZeroPoint - (summary.zeroPoint - 2.5*np.log10(exposureTime))
724 c_eff = min(10**(-2.0*(zeroPointDiff)/2.5), self.config.maxEffectiveTransparency)
726 # Sky brightness component (convert to cts/s)
727 if np.isnan(summary.skyBg):
728 self.log.debug("Sky background is NaN")
729 b_eff = nan
730 elif band not in self.config.fiducialSkyBackground:
731 self.log.debug(f"Fiducial sky background value not found for {band}")
732 b_eff = nan
733 else:
734 fiducialSkyBackground = self.config.fiducialSkyBackground[band]
735 b_eff = fiducialSkyBackground/(summary.skyBg/exposureTime)
737 # Old effective exposure time scale factor
738 # t_eff = f_eff * c_eff * b_eff
740 # New effective exposure time (seconds) from magnitude limit
741 if band not in self.config.fiducialMagLim:
742 self.log.debug(f"Fiducial magnitude limit not found for {band}")
743 effectiveTime = nan
744 elif band not in self.config.fiducialExpTime:
745 self.log.debug(f"Fiducial exposure time not found for {band}")
746 effectiveTime = nan
747 else:
748 effectiveTime = compute_effective_time(summary.magLim,
749 self.config.fiducialMagLim[band],
750 self.config.fiducialExpTime[band])
752 # Output quantities
753 summary.effTime = float(effectiveTime)
754 summary.effTimePsfSigmaScale = float(f_eff)
755 summary.effTimeSkyBgScale = float(b_eff)
756 summary.effTimeZeroPointScale = float(c_eff)
758 def update_magnitude_limit_stats(self, summary, exposure):
759 """Compute a summary statistic for the point-source magnitude limit at
760 a given signal-to-noise ratio using exposure-level metadata.
762 The magnitude limit is calculated at a given SNR from the PSF,
763 sky, zeropoint, and readnoise in accordance with SMTN-002 [1]_,
764 LSE-40 [2]_, and DMTN-296 [3]_.
766 References
767 ----------
769 .. [1] "Calculating LSST limiting magnitudes and SNR" (SMTN-002)
770 .. [2] "Photon Rates and SNR Calculations" (LSE-40)
771 .. [3] "Calculations of Image and Catalog Depth" (DMTN-296)
774 Parameters
775 ----------
776 summary : `lsst.afw.image.ExposureSummaryStats`
777 Summary object to update in-place.
778 exposure : `lsst.afw.image.ExposureF`
779 Exposure to grab band and exposure time metadata
780 """
781 if exposure.getDetector() is None:
782 summary.magLim = float("nan")
783 return
785 # Calculate the average readnoise [e-]
786 readNoiseList = list(ipIsr.getExposureReadNoises(exposure).values())
787 readNoise = np.nanmean(readNoiseList)
788 if np.isnan(readNoise):
789 readNoise = 0.0
790 self.log.warning("Read noise set to NaN! Setting readNoise to 0.0 to proceed.")
792 # Calculate the average gain [e-/ADU]
793 gainList = list(ipIsr.getExposureGains(exposure).values())
794 gain = np.nanmean(gainList)
795 if np.isnan(gain):
796 self.log.warning("Gain set to NaN! Setting magLim to NaN.")
797 summary.magLim = float("nan")
798 return
800 # Get the image units (default to 'adu' if metadata key absent)
801 md = exposure.getMetadata()
802 if md.get("LSST ISR UNITS", "adu") == "electron":
803 gain = 1.0
805 # Convert readNoise to image units
806 readNoise /= gain
808 # Calculate the limiting magnitude.
809 # Note 1: Assumes that the image and readnoise have the same units
810 # Note 2: Assumes that the zeropoint includes the exposure time
811 magLim = compute_magnitude_limit(summary.psfArea, summary.skyBg,
812 summary.zeroPoint, readNoise,
813 gain, self.config.magLimSnr)
815 summary.magLim = float(magLim)
818def maximum_nearest_psf_distance(
819 image_mask,
820 psf_cat,
821 sampling=8,
822 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
823):
824 """Compute the maximum distance of an unmasked pixel to its nearest PSF.
826 Parameters
827 ----------
828 image_mask : `lsst.afw.image.Mask`
829 The mask plane associated with the exposure.
830 psf_cat : `lsst.afw.table.SourceCatalog` or `astropy.table.Table`
831 Catalog containing only the stars used in the PSF modeling.
832 sampling : `int`
833 Sampling rate in each dimension to create the grid of points on which
834 to evaluate the distance to the nearest PSF star. The tradeoff is
835 between adequate sampling versus speed.
836 bad_mask_bits : `list` [`str`]
837 Mask bits required to be absent for a pixel to be considered
838 "unmasked".
840 Returns
841 -------
842 max_dist_to_nearest_psf : `float`
843 The maximum distance (in pixels) of an unmasked pixel to its nearest
844 PSF model star.
845 """
846 mask_arr = image_mask.array[::sampling, ::sampling]
847 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
848 good = ((mask_arr & bitmask) == 0)
850 x = np.arange(good.shape[1]) * sampling
851 y = np.arange(good.shape[0]) * sampling
852 xx, yy = np.meshgrid(x, y)
854 dist_to_nearest_psf = np.full(good.shape, np.inf)
855 for psf in psf_cat:
856 x_psf = psf["slot_Centroid_x"]
857 y_psf = psf["slot_Centroid_y"]
858 dist_to_nearest_psf = np.minimum(dist_to_nearest_psf, np.hypot(xx - x_psf, yy - y_psf))
859 unmasked_dists = dist_to_nearest_psf * good
860 max_dist_to_nearest_psf = np.max(unmasked_dists)
862 return max_dist_to_nearest_psf
865def compute_psf_image_deltas(
866 image_mask,
867 image_psf,
868 sampling=96,
869 ap_radius_pix=3.0,
870 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
871):
872 """Compute the delta between the maximum and minimum model PSF trace radius
873 values evaluated on a grid of points lying in the unmasked region of the
874 image.
876 Parameters
877 ----------
878 image_mask : `lsst.afw.image.Mask`
879 The mask plane associated with the exposure.
880 image_psf : `lsst.afw.detection.Psf`
881 The PSF model associated with the exposure.
882 sampling : `int`, optional
883 Sampling rate in each dimension to create the grid of points at which
884 to evaluate ``image_psf``s trace radius value. The tradeoff is between
885 adequate sampling versus speed.
886 ap_radius_pix : `float`, optional
887 Radius in pixels of the aperture on which to measure the flux of the
888 PSF model.
889 bad_mask_bits : `list` [`str`], optional
890 Mask bits required to be absent for a pixel to be considered
891 "unmasked".
893 Returns
894 -------
895 psf_trace_radius_delta, psf_ap_flux_delta : `float`
896 The delta (in pixels) between the maximum and minimum model PSF trace
897 radius values and the PSF aperture fluxes (with aperture radius of
898 max(2, 3*psfSigma)) evaluated on the x,y-grid subsampled on the
899 unmasked detector pixels by a factor of ``sampling``. If both the
900 model PSF trace radius value and aperture flux value on the grid
901 evaluate to NaN, then NaNs are returned immediately.
902 """
903 psf_trace_radius_list = []
904 psf_ap_flux_list = []
905 mask_arr = image_mask.array[::sampling, ::sampling]
906 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
907 good = ((mask_arr & bitmask) == 0)
909 x = np.arange(good.shape[1]) * sampling
910 y = np.arange(good.shape[0]) * sampling
911 xx, yy = np.meshgrid(x, y)
913 for x_mesh, y_mesh, good_mesh in zip(xx, yy, good):
914 for x_point, y_point, is_good in zip(x_mesh, y_mesh, good_mesh):
915 if is_good:
916 position = geom.Point2D(x_point, y_point)
917 psf_trace_radius = image_psf.computeShape(position).getTraceRadius()
918 psf_ap_flux = image_psf.computeApertureFlux(ap_radius_pix, position)
919 if ~np.isfinite(psf_trace_radius) and ~np.isfinite(psf_ap_flux):
920 return float("nan"), float("nan")
921 psf_trace_radius_list.append(psf_trace_radius)
922 psf_ap_flux_list.append(psf_ap_flux)
924 psf_trace_radius_delta = np.max(psf_trace_radius_list) - np.min(psf_trace_radius_list)
925 if np.any(np.asarray(psf_ap_flux_list) < 0.0): # Consider any -ve flux entry as "bad".
926 psf_ap_flux_delta = float("nan")
927 else:
928 psf_ap_flux_delta = np.max(psf_ap_flux_list) - np.min(psf_ap_flux_list)
930 return psf_trace_radius_delta, psf_ap_flux_delta
933def compute_ap_corr_sigma_scaled_delta(
934 image_mask,
935 image_ap_corr_field,
936 psfSigma,
937 sampling=96,
938 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
939):
940 """Compute the delta between the maximum and minimum aperture correction
941 values scaled (divided) by ``psfSigma`` for the given field representation,
942 ``image_ap_corr_field`` evaluated on a grid of points lying in the
943 unmasked region of the image.
945 Parameters
946 ----------
947 image_mask : `lsst.afw.image.Mask`
948 The mask plane associated with the exposure.
949 image_ap_corr_field : `lsst.afw.math.ChebyshevBoundedField`
950 The ChebyshevBoundedField representation of the aperture correction
951 of interest for the exposure.
952 psfSigma : `float`
953 The PSF model second-moments determinant radius (center of chip)
954 in pixels.
955 sampling : `int`, optional
956 Sampling rate in each dimension to create the grid of points at which
957 to evaluate ``image_psf``s trace radius value. The tradeoff is between
958 adequate sampling versus speed.
959 bad_mask_bits : `list` [`str`], optional
960 Mask bits required to be absent for a pixel to be considered
961 "unmasked".
963 Returns
964 -------
965 ap_corr_sigma_scaled_delta : `float`
966 The delta between the maximum and minimum of the (multiplicative)
967 aperture correction values scaled (divided) by ``psfSigma`` evaluated
968 on the x,y-grid subsampled on the unmasked detector pixels by a factor
969 of ``sampling``. If the aperture correction evaluates to NaN on any
970 of the grid points, this is set to NaN.
971 """
972 mask_arr = image_mask.array[::sampling, ::sampling]
973 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
974 good = ((mask_arr & bitmask) == 0)
976 x = np.arange(good.shape[1], dtype=np.float64) * sampling
977 y = np.arange(good.shape[0], dtype=np.float64) * sampling
978 xx, yy = np.meshgrid(x, y)
980 ap_corr = image_ap_corr_field.evaluate(xx.ravel(), yy.ravel()).reshape(xx.shape)
981 ap_corr_good = ap_corr[good]
982 if ~np.isfinite(ap_corr_good).all():
983 ap_corr_sigma_scaled_delta = float("nan")
984 else:
985 ap_corr_sigma_scaled_delta = (np.max(ap_corr_good) - np.min(ap_corr_good))/psfSigma
987 return ap_corr_sigma_scaled_delta
990def compute_magnitude_limit(
991 psfArea,
992 skyBg,
993 zeroPoint,
994 readNoise,
995 gain,
996 snr
997):
998 """Compute the expected point-source magnitude limit at a given
999 signal-to-noise ratio given the exposure-level metadata. Based on
1000 the signal-to-noise formula provided in SMTN-002 (see LSE-40 for
1001 more details on the calculation).
1003 SNR = C / sqrt( C/g + (B/g + sigma_inst**2) * neff )
1005 where C is the counts from the source, B is counts from the (sky)
1006 background, sigma_inst is the instrumental (read) noise, neff is
1007 the effective size of the PSF, and g is the gain in e-/ADU. Note
1008 that input values of ``skyBg``, ``zeroPoint``, and ``readNoise``
1009 should all consistently be in electrons or ADU.
1011 Parameters
1012 ----------
1013 psfArea : `float`
1014 The effective area of the PSF [pix].
1015 skyBg : `float`
1016 The sky background counts for the exposure [ADU or e-].
1017 zeroPoint : `float`
1018 The zeropoint (includes exposure time) [ADU or e-].
1019 readNoise : `float`
1020 The instrumental read noise for the exposure [ADU or e-].
1021 gain : `float`
1022 The instrumental gain for the exposure [e-/ADU]. The gain should
1023 be 1.0 if the skyBg, zeroPoint, and readNoise are in e-.
1024 snr : `float`
1025 Signal-to-noise ratio at which magnitude limit is calculated.
1027 Returns
1028 -------
1029 magnitude_limit : `float`
1030 The expected magnitude limit at the given signal to noise.
1031 """
1032 # Effective number of pixels within the PSF calculated directly
1033 # from the PSF model.
1034 neff = psfArea
1036 # Instrumental noise (read noise only)
1037 sigma_inst = readNoise
1039 # Total background counts derived from Eq. 45 of LSE-40
1040 background = (skyBg/gain + sigma_inst**2) * neff
1041 # Source counts to achieve the desired SNR (from quadratic formula)
1042 # Note typo in gain exponent in Eq. 45 of LSE-40 (DM-53234)
1043 source = (snr**2)/(2*gain) + np.sqrt((snr**4)/(4*gain**2) + snr**2 * background)
1045 # Convert to a magnitude using the zeropoint.
1046 # Note: Zeropoint currently includes exposure time
1047 magnitude_limit = -2.5*np.log10(source) + zeroPoint
1049 return magnitude_limit
1052def psf_sigma_to_psf_area(psfSigma, pixelScale):
1053 """Convert psfSigma [pixels] to an approximate psfArea [pixel^2] as defined in LSE-40.
1054 This is the same implementation followed by SMTN-002 to estimate SNR=5 magnitude limits.
1056 Parameters
1057 ----------
1058 psfSigma : `float`
1059 The PSF sigma value [pix].
1060 pixelScale : `float`
1061 The pixel scale [arcsec/pix].
1063 Returns
1064 -------
1065 psf_area : `float`
1066 Approximation of the PSF area [pix^2]
1067 """
1068 # Follow SMTN-002 to convert to geometric and effective FWHM
1069 fwhm_geom = psfSigma * 2.355 * pixelScale
1070 fwhm_eff = (fwhm_geom - 0.052) / 0.822
1071 # Area prefactor comes from LSE-40
1072 psf_area = 2.266 * (fwhm_eff / pixelScale)**2
1073 return psf_area
1076def compute_effective_time(
1077 magLim,
1078 fiducialMagLim,
1079 fiducialExpTime
1080):
1081 """Compute the effective exposure time from m5 following the prescription described in SMTN-296.
1083 teff = 10**(0.8 * (magLim - fiducialMagLim) ) * fiducialExpTime
1085 where `magLim` is the magnitude limit, `fiducialMagLim` is the fiducial magnitude limit calculated from
1086 the fiducial values of the ``psfArea``, ``skyBg``, ``zeroPoint``, and ``readNoise``, and
1087 `fiducialExpTime` is the fiducial exposure time (s).
1089 Parameters
1090 ----------
1091 magLim : `float`
1092 The measured magnitude limit [mag].
1093 fiducialMagLim : `float`
1094 The fiducial magnitude limit [mag].
1095 fiducialExpTime : `float`
1096 The fiducial exposure time [s].
1098 Returns
1099 -------
1100 effectiveTime : `float`
1101 The effective exposure time.
1102 """
1103 effectiveTime = 10**(0.8 * (magLim - fiducialMagLim)) * fiducialExpTime
1104 return effectiveTime