LSST Applications g0f08755f38+82efc23009,g12f32b3c4e+e7bdf1200e,g1653933729+a8ce1bb630,g1a0ca8cf93+50eff2b06f,g28da252d5a+52db39f6a5,g2bbee38e9b+37c5a29d61,g2bc492864f+37c5a29d61,g2cdde0e794+c05ff076ad,g3156d2b45e+41e33cbcdc,g347aa1857d+37c5a29d61,g35bb328faa+a8ce1bb630,g3a166c0a6a+37c5a29d61,g3e281a1b8c+fb992f5633,g414038480c+7f03dfc1b0,g41af890bb2+11b950c980,g5fbc88fb19+17cd334064,g6b1c1869cb+12dd639c9a,g781aacb6e4+a8ce1bb630,g80478fca09+72e9651da0,g82479be7b0+04c31367b4,g858d7b2824+82efc23009,g9125e01d80+a8ce1bb630,g9726552aa6+8047e3811d,ga5288a1d22+e532dc0a0b,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gc28159a63d+37c5a29d61,gcf0d15dbbd+2acd6d4d48,gd7358e8bfb+778a810b6e,gda3e153d99+82efc23009,gda6a2b7d83+2acd6d4d48,gdaeeff99f8+1711a396fd,ge2409df99d+6b12de1076,ge79ae78c31+37c5a29d61,gf0baf85859+d0a5978c5a,gf3967379c6+4954f8c433,gfb92a5be7c+82efc23009,gfec2e1e490+2aaed99252,w.2024.46
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 # meas_base/src/PsfFlux.cc#L112. See
339 # https://github.com/lsst/meas_base/blob/
340 # 750bffe6620e565bda731add1509507f5c40c8bb/src/PsfFlux.cc#L112
341 summary.psfArea = float(np.sum(im.array)/np.sum(im.array**2.))
342
343 if image_mask is not None:
344 psfApRadius = max(self.config.minPsfApRadiusPix, 3.0*summary.psfSigma)
345 self.log.debug("Using radius of %.3f (pixels) for psfApFluxDelta metric", psfApRadius)
346 psfTraceRadiusDelta, psfApFluxDelta = compute_psf_image_deltas(
347 image_mask,
348 psf,
349 sampling=self.config.psfGridSampling,
350 ap_radius_pix=psfApRadius,
351 bad_mask_bits=self.config.psfBadMaskPlanes
352 )
353 summary.psfTraceRadiusDelta = float(psfTraceRadiusDelta)
354 summary.psfApFluxDelta = float(psfApFluxDelta)
355 if image_ap_corr_map is not None:
356 if self.config.psfApCorrFieldName not in image_ap_corr_map.keys():
357 self.log.warn(f"{self.config.psfApCorrFieldName} not found in "
358 "image_ap_corr_map. Setting psfApCorrSigmaScaledDelta to NaN.")
359 psfApCorrSigmaScaledDelta = nan
360 else:
361 image_ap_corr_field = image_ap_corr_map[self.config.psfApCorrFieldName]
362 psfApCorrSigmaScaledDelta = compute_ap_corr_sigma_scaled_delta(
363 image_mask,
364 image_ap_corr_field,
365 summary.psfSigma,
366 sampling=self.config.psfGridSampling,
367 bad_mask_bits=self.config.psfBadMaskPlanes,
368 )
369 summary.psfApCorrSigmaScaledDelta = float(psfApCorrSigmaScaledDelta)
370
371 if sources is None:
372 # No sources are available (as in some tests and rare cases where
373 # the selection criteria in finalizeCharacterization lead to no
374 # good sources).
375 return
376
377 # Count the total number of psf stars used (prior to stats selection).
378 nPsfStar = sources[self.config.starSelection].sum()
379 summary.nPsfStar = int(nPsfStar)
380
381 psf_mask = self.starSelector.run(sources).selected
382 nPsfStarsUsedInStats = psf_mask.sum()
383
384 if nPsfStarsUsedInStats == 0:
385 # No stars to measure statistics, so we must return the defaults
386 # of 0 stars and NaN values.
387 return
388
389 if sources_is_astropy:
390 psf_cat = sources[psf_mask]
391 else:
392 psf_cat = sources[psf_mask].copy(deep=True)
393
394 starXX = psf_cat[self.config.starShape + '_xx']
395 starYY = psf_cat[self.config.starShape + '_yy']
396 starXY = psf_cat[self.config.starShape + '_xy']
397 psfXX = psf_cat[self.config.psfShape + '_xx']
398 psfYY = psf_cat[self.config.psfShape + '_yy']
399 psfXY = psf_cat[self.config.psfShape + '_xy']
400
401 # Use the trace radius for the star size.
402 starSize = np.sqrt(starXX/2. + starYY/2.)
403
404 starE1 = (starXX - starYY)/(starXX + starYY)
405 starE2 = 2*starXY/(starXX + starYY)
406 starSizeMedian = np.median(starSize)
407
408 # Use the trace radius for the psf size.
409 psfSize = np.sqrt(psfXX/2. + psfYY/2.)
410 psfE1 = (psfXX - psfYY)/(psfXX + psfYY)
411 psfE2 = 2*psfXY/(psfXX + psfYY)
412
413 psfStarDeltaE1Median = np.median(starE1 - psfE1)
414 psfStarDeltaE1Scatter = sigmaMad(starE1 - psfE1, scale='normal')
415 psfStarDeltaE2Median = np.median(starE2 - psfE2)
416 psfStarDeltaE2Scatter = sigmaMad(starE2 - psfE2, scale='normal')
417
418 psfStarDeltaSizeMedian = np.median(starSize - psfSize)
419 psfStarDeltaSizeScatter = sigmaMad(starSize - psfSize, scale='normal')
420 psfStarScaledDeltaSizeScatter = psfStarDeltaSizeScatter/starSizeMedian
421
422 summary.psfStarDeltaE1Median = float(psfStarDeltaE1Median)
423 summary.psfStarDeltaE2Median = float(psfStarDeltaE2Median)
424 summary.psfStarDeltaE1Scatter = float(psfStarDeltaE1Scatter)
425 summary.psfStarDeltaE2Scatter = float(psfStarDeltaE2Scatter)
426 summary.psfStarDeltaSizeMedian = float(psfStarDeltaSizeMedian)
427 summary.psfStarDeltaSizeScatter = float(psfStarDeltaSizeScatter)
428 summary.psfStarScaledDeltaSizeScatter = float(psfStarScaledDeltaSizeScatter)
429
430 if image_mask is not None:
431 maxDistToNearestPsf = maximum_nearest_psf_distance(
432 image_mask,
433 psf_cat,
434 sampling=self.config.psfSampling,
435 bad_mask_bits=self.config.psfBadMaskPlanes
436 )
437 summary.maxDistToNearestPsf = float(maxDistToNearestPsf)
438
439 def update_wcs_stats(self, summary, wcs, bbox, visitInfo):
440 """Compute all summary-statistic fields that depend on the WCS model.
441
442 Parameters
443 ----------
444 summary : `lsst.afw.image.ExposureSummaryStats`
445 Summary object to update in-place.
446 wcs : `lsst.afw.geom.SkyWcs` or `None`
447 Astrometric calibration model. If `None`, all fields that depend
448 on the WCS will be reset (generally to NaN).
449 bbox : `lsst.geom.Box2I`
450 Bounding box of the image for which summary stats are being
451 computed.
452 visitInfo : `lsst.afw.image.VisitInfo`
453 Observation information used in together with ``wcs`` to compute
454 the zenith distance.
455 """
456 nan = float("nan")
457 summary.raCorners = [nan]*4
458 summary.decCorners = [nan]*4
459 summary.ra = nan
460 summary.dec = nan
461 summary.pixelScale = nan
462 summary.zenithDistance = nan
463
464 if wcs is None:
465 return
466
467 sph_pts = wcs.pixelToSky(geom.Box2D(bbox).getCorners())
468 summary.raCorners = [float(sph.getRa().asDegrees()) for sph in sph_pts]
469 summary.decCorners = [float(sph.getDec().asDegrees()) for sph in sph_pts]
470
471 sph_pt = wcs.pixelToSky(bbox.getCenter())
472 summary.ra = sph_pt.getRa().asDegrees()
473 summary.dec = sph_pt.getDec().asDegrees()
474 summary.pixelScale = wcs.getPixelScale(bbox.getCenter()).asArcseconds()
475
476 date = visitInfo.getDate()
477
478 if date.isValid():
479 # We compute the zenithDistance at the center of the detector
480 # rather than use the boresight value available via the visitInfo,
481 # because the zenithDistance may vary significantly over a large
482 # field of view.
483 observatory = visitInfo.getObservatory()
484 loc = EarthLocation(lat=observatory.getLatitude().asDegrees()*units.deg,
485 lon=observatory.getLongitude().asDegrees()*units.deg,
486 height=observatory.getElevation()*units.m)
487 obstime = Time(visitInfo.getDate().get(system=DateTime.MJD),
488 location=loc, format='mjd')
489 coord = SkyCoord(
490 summary.ra*units.degree,
491 summary.dec*units.degree,
492 obstime=obstime,
493 location=loc,
494 )
495 with warnings.catch_warnings():
496 warnings.simplefilter('ignore')
497 altaz = coord.transform_to(AltAz)
498
499 summary.zenithDistance = float(90.0 - altaz.alt.degree)
500
501 def update_photo_calib_stats(self, summary, photo_calib):
502 """Compute all summary-statistic fields that depend on the photometric
503 calibration model.
504
505 Parameters
506 ----------
507 summary : `lsst.afw.image.ExposureSummaryStats`
508 Summary object to update in-place.
509 photo_calib : `lsst.afw.image.PhotoCalib` or `None`
510 Photometric calibration model. If `None`, all fields that depend
511 on the photometric calibration will be reset (generally to NaN).
512 """
513 if photo_calib is not None:
514 summary.zeroPoint = float(2.5*np.log10(photo_calib.getInstFluxAtZeroMagnitude()))
515 else:
516 summary.zeroPoint = float("nan")
517
518 def update_background_stats(self, summary, background):
519 """Compute summary-statistic fields that depend only on the
520 background model.
521
522 Parameters
523 ----------
524 summary : `lsst.afw.image.ExposureSummaryStats`
525 Summary object to update in-place.
526 background : `lsst.afw.math.BackgroundList` or `None`
527 Background model. If `None`, all fields that depend on the
528 background will be reset (generally to NaN).
529
530 Notes
531 -----
532 This does not include fields that depend on the background-subtracted
533 masked image; when the background changes, it should generally be
534 applied to the image and `update_masked_image_stats` should be called
535 as well.
536 """
537 if background is not None:
538 bgStats = (bg[0].getStatsImage().getImage().array
539 for bg in background)
540 summary.skyBg = float(sum(np.median(bg[np.isfinite(bg)]) for bg in bgStats))
541 else:
542 summary.skyBg = float("nan")
543
544 def update_masked_image_stats(self, summary, masked_image):
545 """Compute summary-statistic fields that depend on the masked image
546 itself.
547
548 Parameters
549 ----------
550 summary : `lsst.afw.image.ExposureSummaryStats`
551 Summary object to update in-place.
552 masked_image : `lsst.afw.image.MaskedImage` or `None`
553 Masked image. If `None`, all fields that depend
554 on the masked image will be reset (generally to NaN).
555 """
556 nan = float("nan")
557 if masked_image is None:
558 summary.skyNoise = nan
559 summary.meanVar = nan
560 return
561 statsCtrl = afwMath.StatisticsControl()
562 statsCtrl.setNumSigmaClip(self.config.sigmaClip)
563 statsCtrl.setNumIter(self.config.clipIter)
564 statsCtrl.setAndMask(afwImage.Mask.getPlaneBitMask(self.config.badMaskPlanes))
565 statsCtrl.setNanSafe(True)
566
567 statObj = afwMath.makeStatistics(masked_image, afwMath.STDEVCLIP, statsCtrl)
568 skyNoise, _ = statObj.getResult(afwMath.STDEVCLIP)
569 summary.skyNoise = skyNoise
570
571 statObj = afwMath.makeStatistics(masked_image.variance, masked_image.mask, afwMath.MEANCLIP,
572 statsCtrl)
573 meanVar, _ = statObj.getResult(afwMath.MEANCLIP)
574 summary.meanVar = meanVar
575
576 def update_effective_time_stats(self, summary, exposure):
577 """Compute effective exposure time statistics to estimate depth.
578
579 The effective exposure time is the equivalent shutter open
580 time that would be needed under nominal conditions to give the
581 same signal-to-noise for a point source as what is achieved by
582 the observation of interest. This metric combines measurements
583 of the point-spread function, the sky brightness, and the
584 transparency. It assumes that the observation is
585 sky-background dominated.
586
587 .. _teff_definitions:
588
589 The effective exposure time and its subcomponents are defined in [1]_.
590
591 References
592 ----------
593
594 .. [1] Neilsen, E.H., Bernstein, G., Gruendl, R., and Kent, S. (2016).
595 Limiting Magnitude, \tau, teff, and Image Quality in DES Year 1
596 https://www.osti.gov/biblio/1250877/
597
598 Parameters
599 ----------
600 summary : `lsst.afw.image.ExposureSummaryStats`
601 Summary object to update in-place.
602 exposure : `lsst.afw.image.ExposureF`
603 Exposure to grab band and exposure time metadata
604
605 """
606 nan = float("nan")
607 summary.effTime = nan
608 summary.effTimePsfSigmaScale = nan
609 summary.effTimeSkyBgScale = nan
610 summary.effTimeZeroPointScale = nan
611
612 exposureTime = exposure.getInfo().getVisitInfo().getExposureTime()
613 filterLabel = exposure.getFilter()
614 if (filterLabel is None) or (not filterLabel.hasBandLabel):
615 band = None
616 else:
617 band = filterLabel.bandLabel
618
619 if band is None:
620 self.log.warn("No band associated with exposure; effTime not calculated.")
621 return
622
623 # PSF component
624 if np.isnan(summary.psfSigma):
625 self.log.debug("PSF sigma is NaN")
626 f_eff = nan
627 elif band not in self.config.fiducialPsfSigma:
628 self.log.debug(f"Fiducial PSF value not found for {band}")
629 f_eff = nan
630 else:
631 fiducialPsfSigma = self.config.fiducialPsfSigma[band]
632 f_eff = (summary.psfSigma / fiducialPsfSigma)**-2
633
634 # Transparency component
635 # Note: Assumes that the zeropoint includes the exposure time
636 if np.isnan(summary.zeroPoint):
637 self.log.debug("Zero point is NaN")
638 c_eff = nan
639 elif band not in self.config.fiducialZeroPoint:
640 self.log.debug(f"Fiducial zero point value not found for {band}")
641 c_eff = nan
642 else:
643 fiducialZeroPoint = self.config.fiducialZeroPoint[band]
644 zeroPointDiff = fiducialZeroPoint - (summary.zeroPoint - 2.5*np.log10(exposureTime))
645 c_eff = min(10**(-2.0*(zeroPointDiff)/2.5), self.config.maxEffectiveTransparency)
646
647 # Sky brightness component (convert to cts/s)
648 if np.isnan(summary.skyBg):
649 self.log.debug("Sky background is NaN")
650 b_eff = nan
651 elif band not in self.config.fiducialSkyBackground:
652 self.log.debug(f"Fiducial sky background value not found for {band}")
653 b_eff = nan
654 else:
655 fiducialSkyBackground = self.config.fiducialSkyBackground[band]
656 b_eff = fiducialSkyBackground/(summary.skyBg/exposureTime)
657
658 # Effective exposure time scale factor
659 t_eff = f_eff * c_eff * b_eff
660
661 # Effective exposure time (seconds)
662 effectiveTime = t_eff * exposureTime
663
664 # Output quantities
665 summary.effTime = float(effectiveTime)
666 summary.effTimePsfSigmaScale = float(f_eff)
667 summary.effTimeSkyBgScale = float(b_eff)
668 summary.effTimeZeroPointScale = float(c_eff)
669
670 def update_magnitude_limit_stats(self, summary, exposure):
671 """Compute a summary statistic for the point-source magnitude limit at
672 a given signal-to-noise ratio using exposure-level metadata.
673
674 The magnitude limit is calculated at a given SNR from the PSF,
675 sky, zeropoint, and readnoise in accordance with SMTN-002 [1]_,
676 LSE-40 [2]_, and DMTN-296 [3]_.
677
678 References
679 ----------
680
681 .. [1] "Calculating LSST limiting magnitudes and SNR" (SMTN-002)
682 .. [2] "Photon Rates and SNR Calculations" (LSE-40)
683 .. [3] "Calculations of Image and Catalog Depth" (DMTN-296)
684
685
686 Parameters
687 ----------
688 summary : `lsst.afw.image.ExposureSummaryStats`
689 Summary object to update in-place.
690 exposure : `lsst.afw.image.ExposureF`
691 Exposure to grab band and exposure time metadata
692 """
693 if exposure.getDetector() is None:
694 summary.magLim = float("nan")
695 return
696
697 # Calculate the average readnoise [e-]
698 readNoiseList = list(ipIsr.getExposureReadNoises(exposure).values())
699 readNoise = np.nanmean(readNoiseList)
700 if np.isnan(readNoise):
701 readNoise = 0.0
702 self.log.warn("Read noise set to NaN! Setting readNoise to 0.0 to proceed.")
703
704 # Calculate the average gain [e-/ADU]
705 gainList = list(ipIsr.getExposureGains(exposure).values())
706 gain = np.nanmean(gainList)
707 if np.isnan(gain):
708 self.log.warn("Gain set to NaN! Setting magLim to NaN.")
709 summary.magLim = float("nan")
710 return
711
712 # Get the image units (default to 'adu' if metadata key absent)
713 md = exposure.getMetadata()
714 if md.get("LSST ISR UNIT", "adu") == "electron":
715 gain = 1.0
716
717 # Convert readNoise to image units
718 readNoise /= gain
719
720 # Calculate the limiting magnitude.
721 # Note 1: Assumes that the image and readnoise have the same units
722 # Note 2: Assumes that the zeropoint includes the exposure time
723 magLim = compute_magnitude_limit(summary.psfArea, summary.skyBg,
724 summary.zeroPoint, readNoise,
725 gain, self.config.magLimSnr)
726
727 summary.magLim = float(magLim)
728
729
731 image_mask,
732 psf_cat,
733 sampling=8,
734 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
735):
736 """Compute the maximum distance of an unmasked pixel to its nearest PSF.
737
738 Parameters
739 ----------
740 image_mask : `lsst.afw.image.Mask`
741 The mask plane associated with the exposure.
742 psf_cat : `lsst.afw.table.SourceCatalog` or `astropy.table.Table`
743 Catalog containing only the stars used in the PSF modeling.
744 sampling : `int`
745 Sampling rate in each dimension to create the grid of points on which
746 to evaluate the distance to the nearest PSF star. The tradeoff is
747 between adequate sampling versus speed.
748 bad_mask_bits : `list` [`str`]
749 Mask bits required to be absent for a pixel to be considered
750 "unmasked".
751
752 Returns
753 -------
754 max_dist_to_nearest_psf : `float`
755 The maximum distance (in pixels) of an unmasked pixel to its nearest
756 PSF model star.
757 """
758 mask_arr = image_mask.array[::sampling, ::sampling]
759 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
760 good = ((mask_arr & bitmask) == 0)
761
762 x = np.arange(good.shape[1]) * sampling
763 y = np.arange(good.shape[0]) * sampling
764 xx, yy = np.meshgrid(x, y)
765
766 dist_to_nearest_psf = np.full(good.shape, np.inf)
767 for psf in psf_cat:
768 x_psf = psf["slot_Centroid_x"]
769 y_psf = psf["slot_Centroid_y"]
770 dist_to_nearest_psf = np.minimum(dist_to_nearest_psf, np.hypot(xx - x_psf, yy - y_psf))
771 unmasked_dists = dist_to_nearest_psf * good
772 max_dist_to_nearest_psf = np.max(unmasked_dists)
773
774 return max_dist_to_nearest_psf
775
776
778 image_mask,
779 image_psf,
780 sampling=96,
781 ap_radius_pix=3.0,
782 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
783):
784 """Compute the delta between the maximum and minimum model PSF trace radius
785 values evaluated on a grid of points lying in the unmasked region of the
786 image.
787
788 Parameters
789 ----------
790 image_mask : `lsst.afw.image.Mask`
791 The mask plane associated with the exposure.
792 image_psf : `lsst.afw.detection.Psf`
793 The PSF model associated with the exposure.
794 sampling : `int`, optional
795 Sampling rate in each dimension to create the grid of points at which
796 to evaluate ``image_psf``s trace radius value. The tradeoff is between
797 adequate sampling versus speed.
798 ap_radius_pix : `float`, optional
799 Radius in pixels of the aperture on which to measure the flux of the
800 PSF model.
801 bad_mask_bits : `list` [`str`], optional
802 Mask bits required to be absent for a pixel to be considered
803 "unmasked".
804
805 Returns
806 -------
807 psf_trace_radius_delta, psf_ap_flux_delta : `float`
808 The delta (in pixels) between the maximum and minimum model PSF trace
809 radius values and the PSF aperture fluxes (with aperture radius of
810 max(2, 3*psfSigma)) evaluated on the x,y-grid subsampled on the
811 unmasked detector pixels by a factor of ``sampling``. If both the
812 model PSF trace radius value and aperture flux value on the grid
813 evaluate to NaN, then NaNs are returned immediately.
814 """
815 psf_trace_radius_list = []
816 psf_ap_flux_list = []
817 mask_arr = image_mask.array[::sampling, ::sampling]
818 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
819 good = ((mask_arr & bitmask) == 0)
820
821 x = np.arange(good.shape[1]) * sampling
822 y = np.arange(good.shape[0]) * sampling
823 xx, yy = np.meshgrid(x, y)
824
825 for x_mesh, y_mesh, good_mesh in zip(xx, yy, good):
826 for x_point, y_point, is_good in zip(x_mesh, y_mesh, good_mesh):
827 if is_good:
828 position = geom.Point2D(x_point, y_point)
829 psf_trace_radius = image_psf.computeShape(position).getTraceRadius()
830 psf_ap_flux = image_psf.computeApertureFlux(ap_radius_pix, position)
831 if ~np.isfinite(psf_trace_radius) and ~np.isfinite(psf_ap_flux):
832 return float("nan"), float("nan")
833 psf_trace_radius_list.append(psf_trace_radius)
834 psf_ap_flux_list.append(psf_ap_flux)
835
836 psf_trace_radius_delta = np.max(psf_trace_radius_list) - np.min(psf_trace_radius_list)
837 if np.any(np.asarray(psf_ap_flux_list) < 0.0): # Consider any -ve flux entry as "bad".
838 psf_ap_flux_delta = float("nan")
839 else:
840 psf_ap_flux_delta = np.max(psf_ap_flux_list) - np.min(psf_ap_flux_list)
841
842 return psf_trace_radius_delta, psf_ap_flux_delta
843
844
846 image_mask,
847 image_ap_corr_field,
848 psfSigma,
849 sampling=96,
850 bad_mask_bits=["BAD", "CR", "INTRP", "SAT", "SUSPECT", "NO_DATA", "EDGE"],
851):
852 """Compute the delta between the maximum and minimum aperture correction
853 values scaled (divided) by ``psfSigma`` for the given field representation,
854 ``image_ap_corr_field`` evaluated on a grid of points lying in the
855 unmasked region of the image.
856
857 Parameters
858 ----------
859 image_mask : `lsst.afw.image.Mask`
860 The mask plane associated with the exposure.
861 image_ap_corr_field : `lsst.afw.math.ChebyshevBoundedField`
862 The ChebyshevBoundedField representation of the aperture correction
863 of interest for the exposure.
864 psfSigma : `float`
865 The PSF model second-moments determinant radius (center of chip)
866 in pixels.
867 sampling : `int`, optional
868 Sampling rate in each dimension to create the grid of points at which
869 to evaluate ``image_psf``s trace radius value. The tradeoff is between
870 adequate sampling versus speed.
871 bad_mask_bits : `list` [`str`], optional
872 Mask bits required to be absent for a pixel to be considered
873 "unmasked".
874
875 Returns
876 -------
877 ap_corr_sigma_scaled_delta : `float`
878 The delta between the maximum and minimum of the (multiplicative)
879 aperture correction values scaled (divided) by ``psfSigma`` evaluated
880 on the x,y-grid subsampled on the unmasked detector pixels by a factor
881 of ``sampling``. If the aperture correction evaluates to NaN on any
882 of the grid points, this is set to NaN.
883 """
884 mask_arr = image_mask.array[::sampling, ::sampling]
885 bitmask = image_mask.getPlaneBitMask(bad_mask_bits)
886 good = ((mask_arr & bitmask) == 0)
887
888 x = np.arange(good.shape[1], dtype=np.float64) * sampling
889 y = np.arange(good.shape[0], dtype=np.float64) * sampling
890 xx, yy = np.meshgrid(x, y)
891
892 ap_corr = image_ap_corr_field.evaluate(xx.ravel(), yy.ravel()).reshape(xx.shape)
893 ap_corr_good = ap_corr[good]
894 if ~np.isfinite(ap_corr_good).all():
895 ap_corr_sigma_scaled_delta = float("nan")
896 else:
897 ap_corr_sigma_scaled_delta = (np.max(ap_corr_good) - np.min(ap_corr_good))/psfSigma
898
899 return ap_corr_sigma_scaled_delta
900
901
903 psfArea,
904 skyBg,
905 zeroPoint,
906 readNoise,
907 gain,
908 snr
909):
910 """Compute the expected point-source magnitude limit at a given
911 signal-to-noise ratio given the exposure-level metadata. Based on
912 the signal-to-noise formula provided in SMTN-002 (see LSE-40 for
913 more details on the calculation).
914
915 SNR = C / sqrt( C/g + (B/g + sigma_inst**2) * neff )
916
917 where C is the counts from the source, B is counts from the (sky)
918 background, sigma_inst is the instrumental (read) noise, neff is
919 the effective size of the PSF, and g is the gain in e-/ADU. Note
920 that input values of ``skyBg``, ``zeroPoint``, and ``readNoise``
921 should all consistently be in electrons or ADU.
922
923 Parameters
924 ----------
925 psfArea : `float`
926 The effective area of the PSF [pix].
927 skyBg : `float`
928 The sky background counts for the exposure [ADU or e-].
929 zeroPoint : `float`
930 The zeropoint (includes exposure time) [ADU or e-].
931 readNoise : `float`
932 The instrumental read noise for the exposure [ADU or e-].
933 gain : `float`
934 The instrumental gain for the exposure [e-/ADU]. The gain should
935 be 1.0 if the skyBg, zeroPoint, and readNoise are in e-.
936 snr : `float`
937 Signal-to-noise ratio at which magnitude limit is calculated.
938
939 Returns
940 -------
941 magnitude_limit : `float`
942 The expected magnitude limit at the given signal to noise.
943
944 """
945 # Effective number of pixels within the PSF calculated directly
946 # from the PSF model.
947 neff = psfArea
948
949 # Instrumental noise (read noise only)
950 sigma_inst = readNoise
951
952 # Total background counts derived from Eq. 45 of LSE-40
953 background = (skyBg/gain + sigma_inst**2) * neff
954 # Source counts to achieve the desired SNR (from quadratic formula)
955 source = (snr**2)/(2*gain) + np.sqrt((snr**4)/(4*gain) + snr**2 * background)
956
957 # Convert to a magnitude using the zeropoint.
958 # Note: Zeropoint currently includes exposure time
959 magnitude_limit = -2.5*np.log10(source) + zeroPoint
960
961 return magnitude_limit
int min
int max
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"])