LSST Applications g013ef56533+d2224463a4,g199a45376c+0ba108daf9,g19c4beb06c+9f335b2115,g1fd858c14a+2459ca3e43,g210f2d0738+2d3d333a78,g262e1987ae+abbb004f04,g2825c19fe3+eedc38578d,g29ae962dfc+0cb55f06ef,g2cef7863aa+aef1011c0b,g35bb328faa+8c5ae1fdc5,g3fd5ace14f+19c3a54948,g47891489e3+501a489530,g4cdb532a89+a047e97985,g511e8cfd20+ce1f47b6d6,g53246c7159+8c5ae1fdc5,g54cd7ddccb+890c8e1e5d,g5fd55ab2c7+951cc3f256,g64539dfbff+2d3d333a78,g67b6fd64d1+501a489530,g67fd3c3899+2d3d333a78,g74acd417e5+0ea5dee12c,g786e29fd12+668abc6043,g87389fa792+8856018cbb,g89139ef638+501a489530,g8d7436a09f+5ea4c44d25,g8ea07a8fe4+81eaaadc04,g90f42f885a+34c0557caf,g9486f8a5af+165c016931,g97be763408+d5e351dcc8,gbf99507273+8c5ae1fdc5,gc2a301910b+2d3d333a78,gca7fc764a6+501a489530,gce8aa8abaa+8c5ae1fdc5,gd7ef33dd92+501a489530,gdab6d2f7ff+0ea5dee12c,ge410e46f29+501a489530,geaed405ab2+e3b4b2a692,gf9a733ac38+8c5ae1fdc5,w.2025.41
LSST Data Management Base Package
Loading...
Searching...
No Matches
computeExposureSummaryStats.py
Go to the documentation of this file.
1# This file is part of pipe_tasks.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21
22__all__ = ["ComputeExposureSummaryStatsTask", "ComputeExposureSummaryStatsConfig"]
23
24import warnings
25import numpy as np
26from scipy.stats import median_abs_deviation as sigmaMad
27import astropy.units as units
28from astropy.time import Time
29from astropy.coordinates import AltAz, SkyCoord, EarthLocation
30from lsst.daf.base import DateTime
31
32import lsst.pipe.base as pipeBase
33import lsst.pex.config as pexConfig
34import lsst.afw.math as afwMath
35import lsst.afw.image as afwImage
36import lsst.geom as geom
37from lsst.meas.algorithms import ScienceSourceSelectorTask
38from lsst.utils.timer import timeMethod
39import lsst.ip.isr as ipIsr
40
41
42class ComputeExposureSummaryStatsConfig(pexConfig.Config):
43 """Config for ComputeExposureSummaryTask"""
44 sigmaClip = pexConfig.Field(
45 dtype=float,
46 doc="Sigma for outlier rejection for sky noise.",
47 default=3.0,
48 )
49 clipIter = pexConfig.Field(
50 dtype=int,
51 doc="Number of iterations of outlier rejection for sky noise.",
52 default=2,
53 )
54 badMaskPlanes = pexConfig.ListField(
55 dtype=str,
56 doc="Mask planes that, if set, the associated pixel should not be included sky noise calculation.",
57 default=("NO_DATA", "SUSPECT"),
58 )
59 starSelection = pexConfig.Field(
60 doc="Field to select full list of sources used for PSF modeling.",
61 dtype=str,
62 default="calib_psf_used",
63 )
64 starSelector = pexConfig.ConfigurableField(
65 target=ScienceSourceSelectorTask,
66 doc="Selection of sources to compute PSF star statistics.",
67 )
68 starShape = pexConfig.Field(
69 doc="Base name of columns to use for the source shape in the PSF statistics computation.",
70 dtype=str,
71 default="slot_Shape"
72 )
73 psfShape = pexConfig.Field(
74 doc="Base name of columns to use for the PSF shape in the PSF statistics computation.",
75 dtype=str,
76 default="slot_PsfShape"
77 )
78 psfSampling = pexConfig.Field(
79 dtype=int,
80 doc="Sampling rate in pixels in each dimension for the maxDistToNearestPsf metric "
81 "caclulation grid (the tradeoff is between adequate sampling versus speed).",
82 default=8,
83 )
84 psfGridSampling = pexConfig.Field(
85 dtype=int,
86 doc="Sampling rate in pixels in each dimension for PSF model robustness metric "
87 "caclulations grid (the tradeoff is between adequate sampling versus speed).",
88 default=96,
89 )
90 minPsfApRadiusPix = pexConfig.Field(
91 dtype=float,
92 doc="Minimum radius in pixels of the aperture within which to measure the flux of "
93 "the PSF model for the psfApFluxDelta metric calculation (the radius is computed as "
94 "max(``minPsfApRadius``, 3*psfSigma)).",
95 default=2.0,
96 )
97 psfApCorrFieldName = pexConfig.Field(
98 doc="Name of the flux column associated with the aperture correction of the PSF model "
99 "to use for the psfApCorrSigmaScaledDelta metric calculation.",
100 dtype=str,
101 default="base_PsfFlux_instFlux"
102 )
103 psfBadMaskPlanes = pexConfig.ListField(
104 dtype=str,
105 doc="Mask planes that, if set, the associated pixel should not be included in the PSF model "
106 "robutsness metric calculations (namely, maxDistToNearestPsf and psfTraceRadiusDelta).",
107 default=("BAD", "CR", "EDGE", "INTRP", "NO_DATA", "SAT", "SUSPECT"),
108 )
109 fiducialSkyBackground = pexConfig.DictField(
110 keytype=str,
111 itemtype=float,
112 doc="Fiducial sky background level (ADU/s) assumed when calculating effective exposure time. "
113 "Keyed by band.",
114 default={'u': 1.0, 'g': 1.0, 'r': 1.0, 'i': 1.0, 'z': 1.0, 'y': 1.0},
115 )
116 fiducialPsfSigma = pexConfig.DictField(
117 keytype=str,
118 itemtype=float,
119 doc="Fiducial PSF sigma (pixels) assumed when calculating effective exposure time. "
120 "Keyed by band.",
121 default={'u': 1.0, 'g': 1.0, 'r': 1.0, 'i': 1.0, 'z': 1.0, 'y': 1.0},
122 )
123 fiducialZeroPoint = pexConfig.DictField(
124 keytype=str,
125 itemtype=float,
126 doc="Fiducial zero point assumed when calculating effective exposure time. "
127 "Keyed by band.",
128 default={'u': 25.0, 'g': 25.0, 'r': 25.0, 'i': 25.0, 'z': 25.0, 'y': 25.0},
129 )
130 maxEffectiveTransparency = pexConfig.Field(
131 dtype=float,
132 doc="Maximum value allowed for effective transparency scale factor (often inf or 1.0).",
133 default=float('inf')
134 )
135 magLimSnr = pexConfig.Field(
136 dtype=float,
137 doc="Signal-to-noise ratio for computing the magnitude limit depth.",
138 default=5.0
139 )
140
141 def setDefaults(self):
142 super().setDefaults()
143
145 self.starSelector.doFlags = True
146 self.starSelector.doSignalToNoise = True
147 self.starSelector.doUnresolved = False
148 self.starSelector.doIsolated = False
149 self.starSelector.doRequireFiniteRaDec = False
150 self.starSelector.doRequirePrimary = False
151
152 self.starSelector.signalToNoise.minimum = 50.0
153 self.starSelector.signalToNoise.maximum = 1000.0
154
155 self.starSelector.flags.bad = ["slot_Shape_flag", "slot_PsfFlux_flag"]
156 # Select stars used for PSF modeling.
157 self.starSelector.flags.good = ["calib_psf_used"]
158
159 self.starSelector.signalToNoise.fluxField = "slot_PsfFlux_instFlux"
160 self.starSelector.signalToNoise.errField = "slot_PsfFlux_instFluxErr"
161
162
164 """Task to compute exposure summary statistics.
165
166 This task computes various quantities suitable for DPDD and other
167 downstream processing at the detector centers, including:
168 - expTime
169 - psfSigma
170 - psfArea
171 - psfIxx
172 - psfIyy
173 - psfIxy
174 - ra
175 - dec
176 - pixelScale (arcsec/pixel)
177 - zenithDistance
178 - zeroPoint
179 - skyBg
180 - skyNoise
181 - meanVar
182 - raCorners
183 - decCorners
184 - astromOffsetMean
185 - astromOffsetStd
186
187 These additional quantities are computed from the stars in the detector:
188 - psfStarDeltaE1Median
189 - psfStarDeltaE2Median
190 - psfStarDeltaE1Scatter
191 - psfStarDeltaE2Scatter
192 - psfStarDeltaSizeMedian
193 - psfStarDeltaSizeScatter
194 - psfStarScaledDeltaSizeScatter
195
196 These quantities are computed based on the PSF model and image mask
197 to assess the robustness of the PSF model across a given detector
198 (against, e.g., extrapolation instability):
199 - maxDistToNearestPsf
200 - psfTraceRadiusDelta
201 - psfApFluxDelta
202
203 This quantity is computed based on the aperture correction map, the
204 psfSigma, and the image mask to assess the robustness of the aperture
205 corrections across a given detector:
206 - psfApCorrSigmaScaledDelta
207
208 These quantities are computed to assess depth:
209 - effTime
210 - effTimePsfSigmaScale
211 - effTimeSkyBgScale
212 - effTimeZeroPointScale
213 - magLim
214 """
215 ConfigClass = ComputeExposureSummaryStatsConfig
216 _DefaultName = "computeExposureSummaryStats"
217
218 def __init__(self, **kwargs):
219 super().__init__(**kwargs)
220
221 self.makeSubtask("starSelector")
222
223 @timeMethod
224 def run(self, exposure, sources, background):
225 """Measure exposure statistics from the exposure, sources, and
226 background.
227
228 Parameters
229 ----------
230 exposure : `lsst.afw.image.ExposureF`
231 sources : `lsst.afw.table.SourceCatalog`
232 background : `lsst.afw.math.BackgroundList`
233
234 Returns
235 -------
236 summary : `lsst.afw.image.ExposureSummary`
237 """
238 self.log.info("Measuring exposure statistics")
239
241
242 # Set exposure time.
243 exposureTime = exposure.getInfo().getVisitInfo().getExposureTime()
244 summary.expTime = exposureTime
245
246 bbox = exposure.getBBox()
247
248 psf = exposure.getPsf()
249 self.update_psf_stats(
250 summary, psf, bbox, sources, image_mask=exposure.mask, image_ap_corr_map=exposure.apCorrMap
251 )
252
253 wcs = exposure.getWcs()
254 visitInfo = exposure.getInfo().getVisitInfo()
255 self.update_wcs_stats(summary, wcs, bbox, visitInfo)
256
257 photoCalib = exposure.getPhotoCalib()
258 self.update_photo_calib_stats(summary, photoCalib)
259
260 self.update_background_stats(summary, background)
261
262 self.update_masked_image_stats(summary, exposure.getMaskedImage())
263
264 self.update_magnitude_limit_stats(summary, exposure)
265
266 self.update_effective_time_stats(summary, exposure)
267
268 md = exposure.getMetadata()
269 if 'SFM_ASTROM_OFFSET_MEAN' in md:
270 summary.astromOffsetMean = md['SFM_ASTROM_OFFSET_MEAN']
271 summary.astromOffsetStd = md['SFM_ASTROM_OFFSET_STD']
272
273 return summary
274
276 self,
277 summary,
278 psf,
279 bbox,
280 sources=None,
281 image_mask=None,
282 image_ap_corr_map=None,
283 sources_is_astropy=False,
284 ):
285 """Compute all summary-statistic fields that depend on the PSF model.
286
287 Parameters
288 ----------
289 summary : `lsst.afw.image.ExposureSummaryStats`
290 Summary object to update in-place.
291 psf : `lsst.afw.detection.Psf` or `None`
292 Point spread function model. If `None`, all fields that depend on
293 the PSF will be reset (generally to NaN).
294 bbox : `lsst.geom.Box2I`
295 Bounding box of the image for which summary stats are being
296 computed.
297 sources : `lsst.afw.table.SourceCatalog` or `astropy.table.Table`
298 Catalog for quantities that are computed from source table columns.
299 If `None`, these quantities will be reset (generally to NaN).
300 The type of this table must correspond to the
301 ``sources_is_astropy`` argument.
302 image_mask : `lsst.afw.image.Mask`, optional
303 Mask image that may be used to compute distance-to-nearest-star
304 metrics.
305 sources_is_astropy : `bool`, optional
306 Whether ``sources`` is an `astropy.table.Table` instance instead
307 of an `lsst.afw.table.Catalog` instance. Default is `False` (the
308 latter).
309 """
310 nan = float("nan")
311 summary.psfSigma = nan
312 summary.psfIxx = nan
313 summary.psfIyy = nan
314 summary.psfIxy = nan
315 summary.psfArea = nan
316 summary.nPsfStar = 0
317 summary.psfStarDeltaE1Median = nan
318 summary.psfStarDeltaE2Median = nan
319 summary.psfStarDeltaE1Scatter = nan
320 summary.psfStarDeltaE2Scatter = nan
321 summary.psfStarDeltaSizeMedian = nan
322 summary.psfStarDeltaSizeScatter = nan
323 summary.psfStarScaledDeltaSizeScatter = nan
324 summary.maxDistToNearestPsf = nan
325 summary.psfTraceRadiusDelta = nan
326 summary.psfApFluxDelta = nan
327 summary.psfApCorrSigmaScaledDelta = nan
328
329 if psf is None:
330 return
331 shape = psf.computeShape(bbox.getCenter())
332 summary.psfSigma = shape.getDeterminantRadius()
333 summary.psfIxx = shape.getIxx()
334 summary.psfIyy = shape.getIyy()
335 summary.psfIxy = shape.getIxy()
336 im = psf.computeKernelImage(bbox.getCenter())
337 # The calculation of effective psf area is taken from
338 # ls.st/srd Equation 1.
339 summary.psfArea = float(np.sum(im.array)**2./np.sum(im.array**2.))
340
341 if image_mask is not None:
342 psfApRadius = max(self.config.minPsfApRadiusPix, 3.0*summary.psfSigma)
343 self.log.debug("Using radius of %.3f (pixels) for psfApFluxDelta metric", psfApRadius)
344 psfTraceRadiusDelta, psfApFluxDelta = compute_psf_image_deltas(
345 image_mask,
346 psf,
347 sampling=self.config.psfGridSampling,
348 ap_radius_pix=psfApRadius,
349 bad_mask_bits=self.config.psfBadMaskPlanes
350 )
351 summary.psfTraceRadiusDelta = float(psfTraceRadiusDelta)
352 summary.psfApFluxDelta = float(psfApFluxDelta)
353 if image_ap_corr_map is not None:
354 if self.config.psfApCorrFieldName not in image_ap_corr_map.keys():
355 self.log.warning(f"{self.config.psfApCorrFieldName} not found in "
356 "image_ap_corr_map. Setting psfApCorrSigmaScaledDelta to NaN.")
357 psfApCorrSigmaScaledDelta = nan
358 else:
359 image_ap_corr_field = image_ap_corr_map[self.config.psfApCorrFieldName]
360 psfApCorrSigmaScaledDelta = compute_ap_corr_sigma_scaled_delta(
361 image_mask,
362 image_ap_corr_field,
363 summary.psfSigma,
364 sampling=self.config.psfGridSampling,
365 bad_mask_bits=self.config.psfBadMaskPlanes,
366 )
367 summary.psfApCorrSigmaScaledDelta = float(psfApCorrSigmaScaledDelta)
368
369 if sources is None:
370 # No sources are available (as in some tests and rare cases where
371 # the selection criteria in finalizeCharacterization lead to no
372 # good sources).
373 return
374
375 # Count the total number of psf stars used (prior to stats selection).
376 nPsfStar = sources[self.config.starSelection].sum()
377 summary.nPsfStar = int(nPsfStar)
378
379 psf_mask = self.starSelector.run(sources).selected
380 nPsfStarsUsedInStats = psf_mask.sum()
381
382 if nPsfStarsUsedInStats == 0:
383 # No stars to measure statistics, so we must return the defaults
384 # of 0 stars and NaN values.
385 return
386
387 if sources_is_astropy:
388 psf_cat = sources[psf_mask]
389 else:
390 psf_cat = sources[psf_mask].copy(deep=True)
391
392 starXX = psf_cat[self.config.starShape + '_xx']
393 starYY = psf_cat[self.config.starShape + '_yy']
394 starXY = psf_cat[self.config.starShape + '_xy']
395 psfXX = psf_cat[self.config.psfShape + '_xx']
396 psfYY = psf_cat[self.config.psfShape + '_yy']
397 psfXY = psf_cat[self.config.psfShape + '_xy']
398
399 # Use the trace radius for the star size.
400 starSize = np.sqrt(starXX/2. + starYY/2.)
401
402 starE1 = (starXX - starYY)/(starXX + starYY)
403 starE2 = 2*starXY/(starXX + starYY)
404 starSizeMedian = np.median(starSize)
405
406 # Use the trace radius for the psf size.
407 psfSize = np.sqrt(psfXX/2. + psfYY/2.)
408 psfE1 = (psfXX - psfYY)/(psfXX + psfYY)
409 psfE2 = 2*psfXY/(psfXX + psfYY)
410
411 psfStarDeltaE1Median = np.median(starE1 - psfE1)
412 psfStarDeltaE1Scatter = sigmaMad(starE1 - psfE1, scale='normal')
413 psfStarDeltaE2Median = np.median(starE2 - psfE2)
414 psfStarDeltaE2Scatter = sigmaMad(starE2 - psfE2, scale='normal')
415
416 psfStarDeltaSizeMedian = np.median(starSize - psfSize)
417 psfStarDeltaSizeScatter = sigmaMad(starSize - psfSize, scale='normal')
418 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian
419
420 summary.psfStarDeltaE1Median = float(psfStarDeltaE1Median)
421 summary.psfStarDeltaE2Median = float(psfStarDeltaE2Median)
422 summary.psfStarDeltaE1Scatter = float(psfStarDeltaE1Scatter)
423 summary.psfStarDeltaE2Scatter = float(psfStarDeltaE2Scatter)
424 summary.psfStarDeltaSizeMedian = float(psfStarDeltaSizeMedian)
425 summary.psfStarDeltaSizeScatter = float(psfStarDeltaSizeScatter)
426 summary.psfStarScaledDeltaSizeScatter = float(psfStarScaledDeltaSizeScatter)
427
428 if image_mask is not None:
429 maxDistToNearestPsf = maximum_nearest_psf_distance(
430 image_mask,
431 psf_cat,
432 sampling=self.config.psfSampling,
433 bad_mask_bits=self.config.psfBadMaskPlanes
434 )
435 summary.maxDistToNearestPsf = float(maxDistToNearestPsf)
436
437 def update_wcs_stats(self, summary, wcs, bbox, visitInfo):
438 """Compute all summary-statistic fields that depend on the WCS model.
439
440 Parameters
441 ----------
442 summary : `lsst.afw.image.ExposureSummaryStats`
443 Summary object to update in-place.
444 wcs : `lsst.afw.geom.SkyWcs` or `None`
445 Astrometric calibration model. If `None`, all fields that depend
446 on the WCS will be reset (generally to NaN).
447 bbox : `lsst.geom.Box2I`
448 Bounding box of the image for which summary stats are being
449 computed.
450 visitInfo : `lsst.afw.image.VisitInfo`
451 Observation information used in together with ``wcs`` to compute
452 the zenith distance.
453 """
454 nan = float("nan")
455 summary.raCorners = [nan]*4
456 summary.decCorners = [nan]*4
457 summary.ra = nan
458 summary.dec = nan
459 summary.pixelScale = nan
460 summary.zenithDistance = nan
461
462 if wcs is None:
463 return
464
465 sph_pts = wcs.pixelToSky(geom.Box2D(bbox).getCorners())
466 summary.raCorners = [float(sph.getRa().asDegrees()) for sph in sph_pts]
467 summary.decCorners = [float(sph.getDec().asDegrees()) for sph in sph_pts]
468
469 sph_pt = wcs.pixelToSky(bbox.getCenter())
470 summary.ra = sph_pt.getRa().asDegrees()
471 summary.dec = sph_pt.getDec().asDegrees()
472 summary.pixelScale = wcs.getPixelScale(bbox.getCenter()).asArcseconds()
473
474 date = visitInfo.getDate()
475
476 if date.isValid():
477 # We compute the zenithDistance at the center of the detector
478 # rather than use the boresight value available via the visitInfo,
479 # because the zenithDistance may vary significantly over a large
480 # field of view.
481 observatory = visitInfo.getObservatory()
482 loc = EarthLocation(lat=observatory.getLatitude().asDegrees()*units.deg,
483 lon=observatory.getLongitude().asDegrees()*units.deg,
484 height=observatory.getElevation()*units.m)
485 obstime = Time(visitInfo.getDate().get(system=DateTime.MJD),
486 location=loc, format='mjd')
487 coord = SkyCoord(
488 summary.ra*units.degree,
489 summary.dec*units.degree,
490 obstime=obstime,
491 location=loc,
492 )
493 with warnings.catch_warnings():
494 warnings.simplefilter('ignore')
495 altaz = coord.transform_to(AltAz)
496
497 summary.zenithDistance = float(90.0 - altaz.alt.degree)
498
499 def update_photo_calib_stats(self, summary, photo_calib):
500 """Compute all summary-statistic fields that depend on the photometric
501 calibration model.
502
503 Parameters
504 ----------
505 summary : `lsst.afw.image.ExposureSummaryStats`
506 Summary object to update in-place.
507 photo_calib : `lsst.afw.image.PhotoCalib` or `None`
508 Photometric calibration model. If `None`, all fields that depend
509 on the photometric calibration will be reset (generally to NaN).
510 """
511 if photo_calib is not None:
512 summary.zeroPoint = float(2.5*np.log10(photo_calib.getInstFluxAtZeroMagnitude()))
513 else:
514 summary.zeroPoint = float("nan")
515
516 def update_background_stats(self, summary, background):
517 """Compute summary-statistic fields that depend only on the
518 background model.
519
520 Parameters
521 ----------
522 summary : `lsst.afw.image.ExposureSummaryStats`
523 Summary object to update in-place.
524 background : `lsst.afw.math.BackgroundList` or `None`
525 Background model. If `None`, all fields that depend on the
526 background will be reset (generally to NaN).
527
528 Notes
529 -----
530 This does not include fields that depend on the background-subtracted
531 masked image; when the background changes, it should generally be
532 applied to the image and `update_masked_image_stats` should be called
533 as well.
534 """
535 if background is not None:
536 bgStats = (bg[0].getStatsImage().getImage().array
537 for bg in background)
538 summary.skyBg = float(sum(np.median(bg[np.isfinite(bg)]) for bg in bgStats))
539 else:
540 summary.skyBg = float("nan")
541
542 def update_masked_image_stats(self, summary, masked_image):
543 """Compute summary-statistic fields that depend on the masked image
544 itself.
545
546 Parameters
547 ----------
548 summary : `lsst.afw.image.ExposureSummaryStats`
549 Summary object to update in-place.
550 masked_image : `lsst.afw.image.MaskedImage` or `None`
551 Masked image. If `None`, all fields that depend
552 on the masked image will be reset (generally to NaN).
553 """
554 nan = float("nan")
555 if masked_image is None:
556 summary.skyNoise = nan
557 summary.meanVar = nan
558 return
559 statsCtrl = afwMath.StatisticsControl()
560 statsCtrl.setNumSigmaClip(self.config.sigmaClip)
561 statsCtrl.setNumIter(self.config.clipIter)
562 statsCtrl.setAndMask(afwImage.Mask.getPlaneBitMask(self.config.badMaskPlanes))
563 statsCtrl.setNanSafe(True)
564
565 statObj = afwMath.makeStatistics(masked_image, afwMath.STDEVCLIP, statsCtrl)
566 skyNoise, _ = statObj.getResult(afwMath.STDEVCLIP)
567 summary.skyNoise = skyNoise
568
569 statObj = afwMath.makeStatistics(masked_image.variance, masked_image.mask, afwMath.MEANCLIP,
570 statsCtrl)
571 meanVar, _ = statObj.getResult(afwMath.MEANCLIP)
572 summary.meanVar = meanVar
573
574 def update_effective_time_stats(self, summary, exposure):
575 """Compute effective exposure time statistics to estimate depth.
576
577 The effective exposure time is the equivalent shutter open
578 time that would be needed under nominal conditions to give the
579 same signal-to-noise for a point source as what is achieved by
580 the observation of interest. This metric combines measurements
581 of the point-spread function, the sky brightness, and the
582 transparency. It assumes that the observation is
583 sky-background dominated.
584
585 .. _teff_definitions:
586
587 The effective exposure time and its subcomponents are defined in [1]_.
588
589 References
590 ----------
591
592 .. [1] Neilsen, E.H., Bernstein, G., Gruendl, R., and Kent, S. (2016).
593 Limiting Magnitude, \tau, teff, and Image Quality in DES Year 1
594 https://www.osti.gov/biblio/1250877/
595
596 Parameters
597 ----------
598 summary : `lsst.afw.image.ExposureSummaryStats`
599 Summary object to update in-place.
600 exposure : `lsst.afw.image.ExposureF`
601 Exposure to grab band and exposure time metadata
602
603 """
604 nan = float("nan")
605 summary.effTime = nan
606 summary.effTimePsfSigmaScale = nan
607 summary.effTimeSkyBgScale = nan
608 summary.effTimeZeroPointScale = nan
609
610 exposureTime = exposure.getInfo().getVisitInfo().getExposureTime()
611 filterLabel = exposure.getFilter()
612 if (filterLabel is None) or (not filterLabel.hasBandLabel):
613 band = None
614 else:
615 band = filterLabel.bandLabel
616
617 if band is None:
618 self.log.warning("No band associated with exposure; effTime not calculated.")
619 return
620
621 # PSF component
622 if np.isnan(summary.psfSigma):
623 self.log.debug("PSF sigma is NaN")
624 f_eff = nan
625 elif band not in self.config.fiducialPsfSigma:
626 self.log.debug(f"Fiducial PSF value not found for {band}")
627 f_eff = nan
628 else:
629 fiducialPsfSigma = self.config.fiducialPsfSigma[band]
630 f_eff = (summary.psfSigma / fiducialPsfSigma)**-2
631
632 # Transparency component
633 # Note: Assumes that the zeropoint includes the exposure time
634 if np.isnan(summary.zeroPoint):
635 self.log.debug("Zero point is NaN")
636 c_eff = nan
637 elif band not in self.config.fiducialZeroPoint:
638 self.log.debug(f"Fiducial zero point value not found for {band}")
639 c_eff = nan
640 else:
641 fiducialZeroPoint = self.config.fiducialZeroPoint[band]
642 zeroPointDiff = fiducialZeroPoint - (summary.zeroPoint - 2.5*np.log10(exposureTime))
643 c_eff = min(10**(-2.0*(zeroPointDiff)/2.5), self.config.maxEffectiveTransparency)
644
645 # Sky brightness component (convert to cts/s)
646 if np.isnan(summary.skyBg):
647 self.log.debug("Sky background is NaN")
648 b_eff = nan
649 elif band not in self.config.fiducialSkyBackground:
650 self.log.debug(f"Fiducial sky background value not found for {band}")
651 b_eff = nan
652 else:
653 fiducialSkyBackground = self.config.fiducialSkyBackground[band]
654 b_eff = fiducialSkyBackground/(summary.skyBg/exposureTime)
655
656 # Effective exposure time scale factor
657 t_eff = f_eff * c_eff * b_eff
658
659 # Effective exposure time (seconds)
660 effectiveTime = t_eff * exposureTime
661
662 # Output quantities
663 summary.effTime = float(effectiveTime)
664 summary.effTimePsfSigmaScale = float(f_eff)
665 summary.effTimeSkyBgScale = float(b_eff)
666 summary.effTimeZeroPointScale = float(c_eff)
667
668 def update_magnitude_limit_stats(self, summary, exposure):
669 """Compute a summary statistic for the point-source magnitude limit at
670 a given signal-to-noise ratio using exposure-level metadata.
671
672 The magnitude limit is calculated at a given SNR from the PSF,
673 sky, zeropoint, and readnoise in accordance with SMTN-002 [1]_,
674 LSE-40 [2]_, and DMTN-296 [3]_.
675
676 References
677 ----------
678
679 .. [1] "Calculating LSST limiting magnitudes and SNR" (SMTN-002)
680 .. [2] "Photon Rates and SNR Calculations" (LSE-40)
681 .. [3] "Calculations of Image and Catalog Depth" (DMTN-296)
682
683
684 Parameters
685 ----------
686 summary : `lsst.afw.image.ExposureSummaryStats`
687 Summary object to update in-place.
688 exposure : `lsst.afw.image.ExposureF`
689 Exposure to grab band and exposure time metadata
690 """
691 if exposure.getDetector() is None:
692 summary.magLim = float("nan")
693 return
694
695 # Calculate the average readnoise [e-]
696 readNoiseList = list(ipIsr.getExposureReadNoises(exposure).values())
697 readNoise = np.nanmean(readNoiseList)
698 if np.isnan(readNoise):
699 readNoise = 0.0
700 self.log.warning("Read noise set to NaN! Setting readNoise to 0.0 to proceed.")
701
702 # Calculate the average gain [e-/ADU]
703 gainList = list(ipIsr.getExposureGains(exposure).values())
704 gain = np.nanmean(gainList)
705 if np.isnan(gain):
706 self.log.warning("Gain set to NaN! Setting magLim to NaN.")
707 summary.magLim = float("nan")
708 return
709
710 # Get the image units (default to 'adu' if metadata key absent)
711 md = exposure.getMetadata()
712 if md.get("LSST ISR UNITS", "adu") == "electron":
713 gain = 1.0
714
715 # Convert readNoise to image units
716 readNoise /= gain
717
718 # Calculate the limiting magnitude.
719 # Note 1: Assumes that the image and readnoise have the same units
720 # Note 2: Assumes that the zeropoint includes the exposure time
721 magLim = compute_magnitude_limit(summary.psfArea, summary.skyBg,
722 summary.zeroPoint, readNoise,
723 gain, self.config.magLimSnr)
724
725 summary.magLim = float(magLim)
726
727
729 image_mask,
730 psf_cat,
731 sampling=8,
732 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
733):
734 """Compute the maximum distance of an unmasked pixel to its nearest PSF.
735
736 Parameters
737 ----------
738 image_mask : `lsst.afw.image.Mask`
739 The mask plane associated with the exposure.
740 psf_cat : `lsst.afw.table.SourceCatalog` or `astropy.table.Table`
741 Catalog containing only the stars used in the PSF modeling.
742 sampling : `int`
743 Sampling rate in each dimension to create the grid of points on which
744 to evaluate the distance to the nearest PSF star. The tradeoff is
745 between adequate sampling versus speed.
746 bad_mask_bits : `list` [`str`]
747 Mask bits required to be absent for a pixel to be considered
748 "unmasked".
749
750 Returns
751 -------
752 max_dist_to_nearest_psf : `float`
753 The maximum distance (in pixels) of an unmasked pixel to its nearest
754 PSF model star.
755 """
756 mask_arr = image_mask.array[::sampling, ::sampling]
757 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
758 good = ((mask_arr & bitmask) == 0)
759
760 x = np.arange(good.shape[1]) * sampling
761 y = np.arange(good.shape[0]) * sampling
762 xx, yy = np.meshgrid(x, y)
763
764 dist_to_nearest_psf = np.full(good.shape, np.inf)
765 for psf in psf_cat:
766 x_psf = psf["slot_Centroid_x"]
767 y_psf = psf["slot_Centroid_y"]
768 dist_to_nearest_psf = np.minimum(dist_to_nearest_psf, np.hypot(xx - x_psf, yy - y_psf))
769 unmasked_dists = dist_to_nearest_psf * good
770 max_dist_to_nearest_psf = np.max(unmasked_dists)
771
772 return max_dist_to_nearest_psf
773
774
776 image_mask,
777 image_psf,
778 sampling=96,
779 ap_radius_pix=3.0,
780 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
781):
782 """Compute the delta between the maximum and minimum model PSF trace radius
783 values evaluated on a grid of points lying in the unmasked region of the
784 image.
785
786 Parameters
787 ----------
788 image_mask : `lsst.afw.image.Mask`
789 The mask plane associated with the exposure.
790 image_psf : `lsst.afw.detection.Psf`
791 The PSF model associated with the exposure.
792 sampling : `int`, optional
793 Sampling rate in each dimension to create the grid of points at which
794 to evaluate ``image_psf``s trace radius value. The tradeoff is between
795 adequate sampling versus speed.
796 ap_radius_pix : `float`, optional
797 Radius in pixels of the aperture on which to measure the flux of the
798 PSF model.
799 bad_mask_bits : `list` [`str`], optional
800 Mask bits required to be absent for a pixel to be considered
801 "unmasked".
802
803 Returns
804 -------
805 psf_trace_radius_delta, psf_ap_flux_delta : `float`
806 The delta (in pixels) between the maximum and minimum model PSF trace
807 radius values and the PSF aperture fluxes (with aperture radius of
808 max(2, 3*psfSigma)) evaluated on the x,y-grid subsampled on the
809 unmasked detector pixels by a factor of ``sampling``. If both the
810 model PSF trace radius value and aperture flux value on the grid
811 evaluate to NaN, then NaNs are returned immediately.
812 """
813 psf_trace_radius_list = []
814 psf_ap_flux_list = []
815 mask_arr = image_mask.array[::sampling, ::sampling]
816 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
817 good = ((mask_arr & bitmask) == 0)
818
819 x = np.arange(good.shape[1]) * sampling
820 y = np.arange(good.shape[0]) * sampling
821 xx, yy = np.meshgrid(x, y)
822
823 for x_mesh, y_mesh, good_mesh in zip(xx, yy, good):
824 for x_point, y_point, is_good in zip(x_mesh, y_mesh, good_mesh):
825 if is_good:
826 position = geom.Point2D(x_point, y_point)
827 psf_trace_radius = image_psf.computeShape(position).getTraceRadius()
828 psf_ap_flux = image_psf.computeApertureFlux(ap_radius_pix, position)
829 if ~np.isfinite(psf_trace_radius) and ~np.isfinite(psf_ap_flux):
830 return float("nan"), float("nan")
831 psf_trace_radius_list.append(psf_trace_radius)
832 psf_ap_flux_list.append(psf_ap_flux)
833
834 psf_trace_radius_delta = np.max(psf_trace_radius_list) - np.min(psf_trace_radius_list)
835 if np.any(np.asarray(psf_ap_flux_list) < 0.0): # Consider any -ve flux entry as "bad".
836 psf_ap_flux_delta = float("nan")
837 else:
838 psf_ap_flux_delta = np.max(psf_ap_flux_list) - np.min(psf_ap_flux_list)
839
840 return psf_trace_radius_delta, psf_ap_flux_delta
841
842
844 image_mask,
845 image_ap_corr_field,
846 psfSigma,
847 sampling=96,
848 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
849):
850 """Compute the delta between the maximum and minimum aperture correction
851 values scaled (divided) by ``psfSigma`` for the given field representation,
852 ``image_ap_corr_field`` evaluated on a grid of points lying in the
853 unmasked region of the image.
854
855 Parameters
856 ----------
857 image_mask : `lsst.afw.image.Mask`
858 The mask plane associated with the exposure.
859 image_ap_corr_field : `lsst.afw.math.ChebyshevBoundedField`
860 The ChebyshevBoundedField representation of the aperture correction
861 of interest for the exposure.
862 psfSigma : `float`
863 The PSF model second-moments determinant radius (center of chip)
864 in pixels.
865 sampling : `int`, optional
866 Sampling rate in each dimension to create the grid of points at which
867 to evaluate ``image_psf``s trace radius value. The tradeoff is between
868 adequate sampling versus speed.
869 bad_mask_bits : `list` [`str`], optional
870 Mask bits required to be absent for a pixel to be considered
871 "unmasked".
872
873 Returns
874 -------
875 ap_corr_sigma_scaled_delta : `float`
876 The delta between the maximum and minimum of the (multiplicative)
877 aperture correction values scaled (divided) by ``psfSigma`` evaluated
878 on the x,y-grid subsampled on the unmasked detector pixels by a factor
879 of ``sampling``. If the aperture correction evaluates to NaN on any
880 of the grid points, this is set to NaN.
881 """
882 mask_arr = image_mask.array[::sampling, ::sampling]
883 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
884 good = ((mask_arr & bitmask) == 0)
885
886 x = np.arange(good.shape[1], dtype=np.float64) * sampling
887 y = np.arange(good.shape[0], dtype=np.float64) * sampling
888 xx, yy = np.meshgrid(x, y)
889
890 ap_corr = image_ap_corr_field.evaluate(xx.ravel(), yy.ravel()).reshape(xx.shape)
891 ap_corr_good = ap_corr[good]
892 if ~np.isfinite(ap_corr_good).all():
893 ap_corr_sigma_scaled_delta = float("nan")
894 else:
895 ap_corr_sigma_scaled_delta = (np.max(ap_corr_good) - np.min(ap_corr_good))/psfSigma
896
897 return ap_corr_sigma_scaled_delta
898
899
901 psfArea,
902 skyBg,
903 zeroPoint,
904 readNoise,
905 gain,
906 snr
907):
908 """Compute the expected point-source magnitude limit at a given
909 signal-to-noise ratio given the exposure-level metadata. Based on
910 the signal-to-noise formula provided in SMTN-002 (see LSE-40 for
911 more details on the calculation).
912
913 SNR = C / sqrt( C/g + (B/g + sigma_inst**2) * neff )
914
915 where C is the counts from the source, B is counts from the (sky)
916 background, sigma_inst is the instrumental (read) noise, neff is
917 the effective size of the PSF, and g is the gain in e-/ADU. Note
918 that input values of ``skyBg``, ``zeroPoint``, and ``readNoise``
919 should all consistently be in electrons or ADU.
920
921 Parameters
922 ----------
923 psfArea : `float`
924 The effective area of the PSF [pix].
925 skyBg : `float`
926 The sky background counts for the exposure [ADU or e-].
927 zeroPoint : `float`
928 The zeropoint (includes exposure time) [ADU or e-].
929 readNoise : `float`
930 The instrumental read noise for the exposure [ADU or e-].
931 gain : `float`
932 The instrumental gain for the exposure [e-/ADU]. The gain should
933 be 1.0 if the skyBg, zeroPoint, and readNoise are in e-.
934 snr : `float`
935 Signal-to-noise ratio at which magnitude limit is calculated.
936
937 Returns
938 -------
939 magnitude_limit : `float`
940 The expected magnitude limit at the given signal to noise.
941
942 """
943 # Effective number of pixels within the PSF calculated directly
944 # from the PSF model.
945 neff = psfArea
946
947 # Instrumental noise (read noise only)
948 sigma_inst = readNoise
949
950 # Total background counts derived from Eq. 45 of LSE-40
951 background = (skyBg/gain + sigma_inst**2) * neff
952 # Source counts to achieve the desired SNR (from quadratic formula)
953 source = (snr**2)/(2*gain) + np.sqrt((snr**4)/(4*gain) + snr**2 * background)
954
955 # Convert to a magnitude using the zeropoint.
956 # Note: Zeropoint currently includes exposure time
957 magnitude_limit = -2.5*np.log10(source) + zeroPoint
958
959 return magnitude_limit
Pass parameters to a Statistics object.
Definition Statistics.h:83
A floating-point coordinate rectangle geometry.
Definition Box.h:413
update_psf_stats(self, summary, psf, bbox, sources=None, image_mask=None, image_ap_corr_map=None, sources_is_astropy=False)
Statistics makeStatistics(lsst::afw::image::Image< Pixel > const &img, lsst::afw::image::Mask< image::MaskPixel > const &msk, int const flags, StatisticsControl const &sctrl=StatisticsControl())
Handle a watered-down front-end to the constructor (no variance)
Definition Statistics.h:361
compute_magnitude_limit(psfArea, skyBg, zeroPoint, readNoise, gain, snr)
maximum_nearest_psf_distance(image_mask, psf_cat, sampling=8, bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"])
compute_ap_corr_sigma_scaled_delta(image_mask, image_ap_corr_field, psfSigma, sampling=96, bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"])
compute_psf_image_deltas(image_mask, image_psf, sampling=96, ap_radius_pix=3.0, bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"])