Coverage for python/lsst/cp/verify/verifyStats.py: 28%
167 statements
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-09 04:06 -0700
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-09 04:06 -0700
1# This file is part of cp_verify.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://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 <http://www.gnu.org/licenses/>.
21import math
23import lsst.afw.geom as afwGeom
24import lsst.afw.math as afwMath
25import lsst.pex.config as pexConfig
26import lsst.pex.exceptions as pexException
27import lsst.pipe.base as pipeBase
28import lsst.pipe.base.connectionTypes as cT
29import lsst.meas.algorithms as measAlg
31from lsst.ip.isr.vignette import maskVignettedRegion
32from lsst.pipe.tasks.repair import RepairTask
33from .utils import mergeStatDict
36__all__ = ['CpVerifyStatsConfig', 'CpVerifyStatsTask']
39class CpVerifyStatsConnections(pipeBase.PipelineTaskConnections,
40 dimensions={"instrument", "exposure", "detector"},
41 defaultTemplates={}):
42 inputExp = cT.Input(
43 name="postISRCCD",
44 doc="Input exposure to calculate statistics for.",
45 storageClass="Exposure",
46 dimensions=["instrument", "exposure", "detector"],
47 )
48 taskMetadata = cT.Input(
49 name="isrTask_metadata",
50 doc="Input task metadata to extract statistics from.",
51 storageClass="TaskMetadata",
52 dimensions=["instrument", "exposure", "detector"],
53 )
54 inputCatalog = cT.Input(
55 name="src",
56 doc="Input catalog to calculate statistics for.",
57 storageClass="SourceCatalog",
58 dimensions=["instrument", "visit", "detector"],
59 )
60 uncorrectedCatalog = cT.Input(
61 name="uncorrectedSrc",
62 doc="Input catalog without correction applied.",
63 storageClass="SourceCatalog",
64 dimensions=["instrument", "visit", "detector"],
65 )
66 camera = cT.PrerequisiteInput(
67 name="camera",
68 storageClass="Camera",
69 doc="Input camera.",
70 dimensions=["instrument", ],
71 isCalibration=True,
72 )
74 outputStats = cT.Output(
75 name="detectorStats",
76 doc="Output statistics from cp_verify.",
77 storageClass="StructuredDataDict",
78 dimensions=["instrument", "exposure", "detector"],
79 )
81 def __init__(self, *, config=None):
82 super().__init__(config=config)
84 if len(config.metadataStatKeywords) < 1:
85 self.inputs.discard('taskMetadata')
87 if len(config.catalogStatKeywords) < 1:
88 self.inputs.discard('inputCatalog')
89 self.inputs.discard('uncorrectedCatalog')
92class CpVerifyStatsConfig(pipeBase.PipelineTaskConfig,
93 pipelineConnections=CpVerifyStatsConnections):
94 """Configuration parameters for CpVerifyStatsTask.
95 """
96 maskNameList = pexConfig.ListField(
97 dtype=str,
98 doc="Mask list to exclude from statistics calculations.",
99 default=['DETECTED', 'BAD', 'NO_DATA'],
100 )
101 doVignette = pexConfig.Field(
102 dtype=bool,
103 doc="Mask vignetted regions?",
104 default=False,
105 )
106 doNormalize = pexConfig.Field(
107 dtype=bool,
108 doc="Normalize by exposure time?",
109 default=False,
110 )
112 # Cosmic ray handling options.
113 doCR = pexConfig.Field(
114 dtype=bool,
115 doc="Run CR rejection?",
116 default=False,
117 )
118 repair = pexConfig.ConfigurableField(
119 target=RepairTask,
120 doc="Repair task to use.",
121 )
122 psfFwhm = pexConfig.Field(
123 dtype=float,
124 default=3.0,
125 doc="Repair PSF FWHM (pixels).",
126 )
127 psfSize = pexConfig.Field(
128 dtype=int,
129 default=21,
130 doc="Repair PSF bounding-box size (pixels).",
131 )
132 crGrow = pexConfig.Field(
133 dtype=int,
134 default=0,
135 doc="Grow radius for CR (pixels).",
136 )
138 # Statistics options.
139 useReadNoise = pexConfig.Field(
140 dtype=bool,
141 doc="Compare sigma against read noise?",
142 default=True,
143 )
144 numSigmaClip = pexConfig.Field(
145 dtype=float,
146 doc="Rejection threshold (sigma) for statistics clipping.",
147 default=5.0,
148 )
149 clipMaxIter = pexConfig.Field(
150 dtype=int,
151 doc="Max number of clipping iterations to apply.",
152 default=3,
153 )
155 # Keywords and statistics to measure from different sources.
156 imageStatKeywords = pexConfig.DictField(
157 keytype=str,
158 itemtype=str,
159 doc="Image statistics to run on amplifier segments.",
160 default={},
161 )
162 unmaskedImageStatKeywords = pexConfig.DictField(
163 keytype=str,
164 itemtype=str,
165 doc="Image statistics to run on amplifier segments, ignoring masks.",
166 default={},
167 )
168 crImageStatKeywords = pexConfig.DictField(
169 keytype=str,
170 itemtype=str,
171 doc="Image statistics to run on CR cleaned amplifier segments.",
172 default={},
173 )
174 normImageStatKeywords = pexConfig.DictField(
175 keytype=str,
176 itemtype=str,
177 doc="Image statistics to run on expTime normalized amplifier segments.",
178 default={},
179 )
180 metadataStatKeywords = pexConfig.DictField(
181 keytype=str,
182 itemtype=str,
183 doc="Statistics to measure from the metadata of the exposure.",
184 default={},
185 )
186 catalogStatKeywords = pexConfig.DictField(
187 keytype=str,
188 itemtype=str,
189 doc="Statistics to measure from source catalogs of objects in the exposure.",
190 default={},
191 )
192 detectorStatKeywords = pexConfig.DictField(
193 keytype=str,
194 itemtype=str,
195 doc="Statistics to create for the full detector from the per-amplifier measurements.",
196 default={},
197 )
200class CpVerifyStatsTask(pipeBase.PipelineTask, pipeBase.CmdLineTask):
201 """Main statistic measurement and validation class.
203 This operates on a single (exposure, detector) pair, and is
204 designed to be subclassed so specific calibrations can apply their
205 own validation methods.
206 """
207 ConfigClass = CpVerifyStatsConfig
208 _DefaultName = 'cpVerifyStats'
210 def __init__(self, **kwargs):
211 super().__init__(**kwargs)
212 self.makeSubtask("repair")
214 def run(self, inputExp, camera, taskMetadata=None, inputCatalog=None, uncorrectedCatalog=None):
215 """Calculate quality statistics and verify they meet the requirements
216 for a calibration.
218 Parameters
219 ----------
220 inputExp : `lsst.afw.image.Exposure`
221 The ISR processed exposure to be measured.
222 camera : `lsst.afw.cameraGeom.Camera`
223 The camera geometry for ``inputExp``.
224 taskMetadata : `lsst.pipe.base.TaskMetadata`, optional
225 Task metadata containing additional statistics.
226 inputCatalog : `lsst.afw.image.Table`
227 The source catalog to measure.
228 uncorrectedCatalog : `lsst.afw.image.Table`
229 The alternate source catalog to measure.
231 Returns
232 -------
233 result : `lsst.pipe.base.Struct`
234 Result struct with components:
235 - ``outputStats`` : `dict`
236 The output measured statistics.
238 Notes
239 -----
240 The outputStats should have a yaml representation of the form
242 AMP:
243 Amp1:
244 STAT: value
245 STAT2: value2
246 Amp2:
247 Amp3:
248 DET:
249 STAT: value
250 STAT2: value
251 CATALOG:
252 STAT: value
253 STAT2: value
254 VERIFY:
255 DET:
256 TEST: boolean
257 CATALOG:
258 TEST: boolean
259 AMP:
260 Amp1:
261 TEST: boolean
262 TEST2: boolean
263 Amp2:
264 Amp3:
265 SUCCESS: boolean
267 """
268 outputStats = {}
270 if self.config.doVignette:
271 polygon = inputExp.getInfo().getValidPolygon()
272 maskVignettedRegion(inputExp, polygon, maskPlane='NO_DATA',
273 vignetteValue=None, log=self.log)
275 mask = inputExp.getMask()
276 maskVal = mask.getPlaneBitMask(self.config.maskNameList)
277 statControl = afwMath.StatisticsControl(self.config.numSigmaClip,
278 self.config.clipMaxIter,
279 maskVal)
281 # This is wrapped below to check for config lengths, as we can
282 # make a number of different image stats.
283 outputStats['AMP'] = self.imageStatistics(inputExp, statControl)
285 if len(self.config.metadataStatKeywords):
286 # These are also defined on a amp-by-amp basis.
287 outputStats['METADATA'] = self.metadataStatistics(inputExp, taskMetadata)
288 else:
289 outputStats['METADATA'] = {}
291 if len(self.config.catalogStatKeywords):
292 outputStats['CATALOG'] = self.catalogStatistics(inputExp, inputCatalog, uncorrectedCatalog,
293 statControl)
294 else:
295 outputStats['CATALOG'] = {}
296 if len(self.config.detectorStatKeywords):
297 outputStats['DET'] = self.detectorStatistics(outputStats, statControl)
298 else:
299 outputStats['DET'] = {}
301 outputStats['VERIFY'], outputStats['SUCCESS'] = self.verify(inputExp, outputStats)
303 return pipeBase.Struct(
304 outputStats=outputStats,
305 )
307 @staticmethod
308 def _emptyAmpDict(exposure):
309 """Construct empty dictionary indexed by amplifier names.
311 Parameters
312 ----------
313 exposure : `lsst.afw.image.Exposure`
314 Exposure to extract detector from.
316 Returns
317 -------
318 outputStatistics : `dict` [`str`, `dict`]
319 A skeleton statistics dictionary.
321 Raises
322 ------
323 RuntimeError :
324 Raised if no detector can be found.
325 """
326 outputStatistics = {}
327 detector = exposure.getDetector()
328 if detector is None:
329 raise RuntimeError("No detector found in exposure!")
331 for amp in detector.getAmplifiers():
332 outputStatistics[amp.getName()] = {}
334 return outputStatistics
336 # Image measurement methods.
337 def imageStatistics(self, exposure, statControl):
338 """Measure image statistics for a number of simple image
339 modifications.
341 Parameters
342 ----------
343 exposure : `lsst.afw.image.Exposure`
344 Exposure containing the ISR processed data to measure.
345 statControl : `lsst.afw.math.StatisticsControl`
346 Statistics control object with parameters defined by
347 the config.
349 Returns
350 -------
351 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
352 A dictionary indexed by the amplifier name, containing
353 dictionaries of the statistics measured and their values.
355 """
356 outputStatistics = self._emptyAmpDict(exposure)
358 if len(self.config.imageStatKeywords):
359 outputStatistics = mergeStatDict(outputStatistics,
360 self.amplifierStats(exposure,
361 self.config.imageStatKeywords,
362 statControl))
363 if len(self.config.unmaskedImageStatKeywords):
364 outputStatistics = mergeStatDict(outputStatistics, self.unmaskedImageStats(exposure))
366 if len(self.config.normImageStatKeywords):
367 outputStatistics = mergeStatDict(outputStatistics,
368 self.normalizedImageStats(exposure, statControl))
370 if len(self.config.crImageStatKeywords):
371 outputStatistics = mergeStatDict(outputStatistics,
372 self.crImageStats(exposure, statControl))
374 return outputStatistics
376 @staticmethod
377 def _configHelper(keywordDict):
378 """Helper to convert keyword dictionary to stat value.
380 Convert the string names in the keywordDict to the afwMath values.
381 The statisticToRun is then the bitwise-or of that set.
383 Parameters
384 ----------
385 keywordDict : `dict` [`str`, `str`]
386 A dictionary of keys to use in the output results, with
387 values the string name associated with the
388 `lsst.afw.math.statistics.Property` to measure.
390 Returns
391 -------
392 statisticToRun : `int`
393 The merged `lsst.afw.math` statistics property.
394 statAccessor : `dict` [`str`, `int`]
395 Dictionary containing statistics property indexed by name.
396 """
397 statisticToRun = 0
398 statAccessor = {}
399 for k, v in keywordDict.items():
400 statValue = afwMath.stringToStatisticsProperty(v)
401 statisticToRun |= statValue
402 statAccessor[k] = statValue
404 return statisticToRun, statAccessor
406 def metadataStatistics(self, exposure, taskMetadata):
407 """Extract task metadata information for verification.
409 Parameters
410 ----------
411 exposure : `lsst.afw.image.Exposure`
412 The exposure to measure.
413 taskMetadata : `lsst.pipe.base.TaskMetadata`
414 The metadata to extract values from.
416 Returns
417 -------
418 ampStats : `dict` [`str`, `dict` [`str`, scalar]]
419 A dictionary indexed by the amplifier name, containing
420 dictionaries of the statistics measured and their values.
421 """
422 metadataStats = {}
423 keywordDict = self.config.metadataStatKeywords
425 if taskMetadata:
426 for key, value in keywordDict.items():
427 if value == 'AMP':
428 metadataStats[key] = {}
429 for ampIdx, amp in enumerate(exposure.getDetector()):
430 ampName = amp.getName()
431 expectedKey = f"{key} {ampName}"
432 metadataStats[key][ampName] = None
433 for name in taskMetadata:
434 if expectedKey in taskMetadata[name]:
435 metadataStats[key][ampName] = taskMetadata[name][expectedKey]
436 else:
437 # Assume it's detector-wide.
438 expectedKey = key
439 for name in taskMetadata:
440 if expectedKey in taskMetadata[name]:
441 metadataStats[key] = taskMetadata[name][expectedKey]
442 return metadataStats
444 def amplifierStats(self, exposure, keywordDict, statControl, failAll=False):
445 """Measure amplifier level statistics from the exposure.
447 Parameters
448 ----------
449 exposure : `lsst.afw.image.Exposure`
450 The exposure to measure.
451 keywordDict : `dict` [`str`, `str`]
452 A dictionary of keys to use in the output results, with
453 values the string name associated with the
454 `lsst.afw.math.statistics.Property` to measure.
455 statControl : `lsst.afw.math.StatisticsControl`
456 Statistics control object with parameters defined by
457 the config.
458 failAll : `bool`, optional
459 If True, all tests will be set as failed.
461 Returns
462 -------
463 ampStats : `dict` [`str`, `dict` [`str`, scalar]]
464 A dictionary indexed by the amplifier name, containing
465 dictionaries of the statistics measured and their values.
466 """
467 ampStats = {}
469 statisticToRun, statAccessor = self._configHelper(keywordDict)
471 # Measure stats on all amplifiers.
472 for ampIdx, amp in enumerate(exposure.getDetector()):
473 ampName = amp.getName()
474 theseStats = {}
475 ampExp = exposure.Factory(exposure, amp.getBBox())
476 stats = afwMath.makeStatistics(ampExp.getMaskedImage(), statisticToRun, statControl)
478 for k, v in statAccessor.items():
479 theseStats[k] = stats.getValue(v)
481 if failAll:
482 theseStats['FORCE_FAILURE'] = failAll
483 ampStats[ampName] = theseStats
485 return ampStats
487 def unmaskedImageStats(self, exposure):
488 """Measure amplifier level statistics on the exposure, including all
489 pixels in the exposure, regardless of any mask planes set.
491 Parameters
492 ----------
493 exposure : `lsst.afw.image.Exposure`
494 The exposure to measure.
496 Returns
497 -------
498 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
499 A dictionary indexed by the amplifier name, containing
500 dictionaries of the statistics measured and their values.
501 """
502 noMaskStatsControl = afwMath.StatisticsControl(self.config.numSigmaClip,
503 self.config.clipMaxIter,
504 0x0)
505 return self.amplifierStats(exposure, self.config.unmaskedImageStatKeywords, noMaskStatsControl)
507 def normalizedImageStats(self, exposure, statControl):
508 """Measure amplifier level statistics on the exposure after dividing
509 by the exposure time.
511 Parameters
512 ----------
513 exposure : `lsst.afw.image.Exposure`
514 The exposure to measure.
515 statControl : `lsst.afw.math.StatisticsControl`
516 Statistics control object with parameters defined by
517 the config.
519 Returns
520 -------
521 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
522 A dictionary indexed by the amplifier name, containing
523 dictionaries of the statistics measured and their values.
525 Raises
526 ------
527 RuntimeError :
528 Raised if the exposure time cannot be used for normalization.
529 """
530 scaledExposure = exposure.clone()
531 exposureTime = scaledExposure.getInfo().getVisitInfo().getExposureTime()
532 if exposureTime <= 0:
533 raise RuntimeError(f"Invalid exposureTime {exposureTime}.")
534 mi = scaledExposure.getMaskedImage()
535 mi /= exposureTime
537 return self.amplifierStats(scaledExposure, self.config.normImageStatKeywords, statControl)
539 def crImageStats(self, exposure, statControl):
540 """Measure amplifier level statistics on the exposure,
541 after running cosmic ray rejection.
543 Parameters
544 ----------
545 exposure : `lsst.afw.image.Exposure`
546 The exposure to measure.
547 statControl : `lsst.afw.math.StatisticsControl`
548 Statistics control object with parameters defined by
549 the config.
551 Returns
552 -------
553 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
554 A dictionary indexed by the amplifier name, containing
555 dictionaries of the statistics measured and their values.
557 """
558 crRejectedExp = exposure.clone()
559 psf = measAlg.SingleGaussianPsf(self.config.psfSize,
560 self.config.psfSize,
561 self.config.psfFwhm/(2*math.sqrt(2*math.log(2))))
562 crRejectedExp.setPsf(psf)
563 try:
564 self.repair.run(crRejectedExp, keepCRs=False)
565 failAll = False
566 except pexException.LengthError:
567 self.log.warning("Failure masking cosmic rays (too many found). Continuing.")
568 failAll = True
570 if self.config.crGrow > 0:
571 crMask = crRejectedExp.getMaskedImage().getMask().getPlaneBitMask("CR")
572 spans = afwGeom.SpanSet.fromMask(crRejectedExp.mask, crMask)
573 spans = spans.dilated(self.config.crGrow)
574 spans = spans.clippedTo(crRejectedExp.getBBox())
575 spans.setMask(crRejectedExp.mask, crMask)
577 return self.amplifierStats(crRejectedExp, self.config.crImageStatKeywords,
578 statControl, failAll=failAll)
580 # Methods that need to be implemented by the calibration-level subclasses.
581 def catalogStatistics(self, exposure, catalog, uncorrectedCatalog, statControl):
582 """Calculate statistics from a catalog.
584 Parameters
585 ----------
586 exposure : `lsst.afw.image.Exposure`
587 The exposure to measure.
588 catalog : `lsst.afw.table.Table`
589 The catalog to measure.
590 uncorrectedCatalog : `lsst.afw.table.Table`
591 The alternate catalog to measure.
592 statControl : `lsst.afw.math.StatisticsControl`
593 Statistics control object with parameters defined by
594 the config.
596 Returns
597 -------
598 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]]
599 A dictionary indexed by the amplifier name, containing
600 dictionaries of the statistics measured and their values.
601 """
602 raise NotImplementedError("Subclasses must implement catalog statistics method.")
604 def detectorStatistics(self, statisticsDict, statControl):
605 """Calculate detector level statistics based on the existing
606 per-amplifier measurements.
608 Parameters
609 ----------
610 statisticsDictionary : `dict` [`str`, `dict` [`str`, scalar]],
611 Dictionary of measured statistics. The inner dictionary
612 should have keys that are statistic names (`str`) with
613 values that are some sort of scalar (`int` or `float` are
614 the mostly likely types).
616 Returns
617 -------
618 outputStatistics : `dict` [`str`, scalar]
619 A dictionary of the statistics measured and their values.
621 Raises
622 ------
623 NotImplementedError :
624 This method must be implemented by the calibration-type
625 subclass.
626 """
627 raise NotImplementedError("Subclasses must implement detector statistics method.")
629 def verify(self, exposure, statisticsDict):
630 """Verify that the measured statistics meet the verification criteria.
632 Parameters
633 ----------
634 exposure : `lsst.afw.image.Exposure`
635 The exposure the statistics are from.
636 statisticsDictionary : `dict` [`str`, `dict` [`str`, scalar]],
637 Dictionary of measured statistics. The inner dictionary
638 should have keys that are statistic names (`str`) with
639 values that are some sort of scalar (`int` or `float` are
640 the mostly likely types).
642 Returns
643 -------
644 outputStatistics : `dict` [`str`, `dict` [`str`, `bool`]]
645 A dictionary indexed by the amplifier name, containing
646 dictionaries of the verification criteria.
647 success : `bool`
648 A boolean indicating whether all tests have passed.
650 Raises
651 ------
652 NotImplementedError :
653 This method must be implemented by the calibration-type
654 subclass.
655 """
656 raise NotImplementedError("Subclasses must implement verification criteria.")