LSST Applications g04e9c324dd+8c5ae1fdc5,g134cb467dc+b203dec576,g18429d2f64+358861cd2c,g199a45376c+0ba108daf9,g1fd858c14a+dd066899e3,g262e1987ae+ebfced1d55,g29ae962dfc+72fd90588e,g2cef7863aa+aef1011c0b,g35bb328faa+8c5ae1fdc5,g3fd5ace14f+b668f15bc5,g4595892280+3897dae354,g47891489e3+abcf9c3559,g4d44eb3520+fb4ddce128,g53246c7159+8c5ae1fdc5,g67b6fd64d1+abcf9c3559,g67fd3c3899+1f72b5a9f7,g74acd417e5+cb6b47f07b,g786e29fd12+668abc6043,g87389fa792+8856018cbb,g89139ef638+abcf9c3559,g8d7436a09f+bcf525d20c,g8ea07a8fe4+9f5ccc88ac,g90f42f885a+6054cc57f1,g97be763408+06f794da49,g9dd6db0277+1f72b5a9f7,ga681d05dcb+7e36ad54cd,gabf8522325+735880ea63,gac2eed3f23+abcf9c3559,gb89ab40317+abcf9c3559,gbf99507273+8c5ae1fdc5,gd8ff7fe66e+1f72b5a9f7,gdab6d2f7ff+cb6b47f07b,gdc713202bf+1f72b5a9f7,gdfd2d52018+8225f2b331,ge365c994fd+375fc21c71,ge410e46f29+abcf9c3559,geaed405ab2+562b3308c0,gf9a733ac38+8c5ae1fdc5,w.2025.35
LSST Data Management Base Package
Loading...
Searching...
No Matches
isrTaskLSST.py
Go to the documentation of this file.
1__all__ = ["IsrTaskLSST", "IsrTaskLSSTConfig"]
2
3import numpy
4import math
5
6from . import isrFunctions
7from . import isrQa
8from .defects import Defects
9
10from contextlib import contextmanager
11from deprecated.sphinx import deprecated
12import lsst.pex.config as pexConfig
13import lsst.afw.math as afwMath
14import lsst.pipe.base as pipeBase
15import lsst.afw.image as afwImage
16import lsst.pipe.base.connectionTypes as cT
17from lsst.pipe.base import UnprocessableDataError
18from lsst.meas.algorithms.detection import SourceDetectionTask
19import lsst.afw.detection as afwDetection
20
21from .ampOffset import AmpOffsetTask
22from .binExposureTask import BinExposureTask
23from .overscan import SerialOverscanCorrectionTask, ParallelOverscanCorrectionTask
24from .overscanAmpConfig import OverscanCameraConfig
25from .assembleCcdTask import AssembleCcdTask
26from .deferredCharge import DeferredChargeTask
27from .crosstalk import CrosstalkTask
28from .masking import MaskingTask
29from .isrStatistics import IsrStatisticsTask
30from .isr import maskNans
31from .ptcDataset import PhotonTransferCurveDataset
32from .isrFunctions import isTrimmedExposure, compareCameraKeywords
33
34
35class IsrTaskLSSTConnections(pipeBase.PipelineTaskConnections,
36 dimensions={"instrument", "exposure", "detector"},
37 defaultTemplates={}):
38 ccdExposure = cT.Input(
39 name="raw",
40 doc="Input exposure to process.",
41 storageClass="Exposure",
42 dimensions=["instrument", "exposure", "detector"],
43 )
44 camera = cT.PrerequisiteInput(
45 name="camera",
46 storageClass="Camera",
47 doc="Input camera to construct complete exposures.",
48 dimensions=["instrument"],
49 isCalibration=True,
50 )
51 dnlLUT = cT.PrerequisiteInput(
52 name="dnlLUT",
53 doc="Look-up table for differential non-linearity.",
54 storageClass="IsrCalib",
55 dimensions=["instrument", "exposure", "detector"],
56 isCalibration=True,
57 # TODO DM 36636
58 )
59 bias = cT.PrerequisiteInput(
60 name="bias",
61 doc="Input bias calibration.",
62 storageClass="ExposureF",
63 dimensions=["instrument", "detector"],
64 isCalibration=True,
65 )
66 deferredChargeCalib = cT.PrerequisiteInput(
67 name="cti",
68 doc="Deferred charge/CTI correction dataset.",
69 storageClass="IsrCalib",
70 dimensions=["instrument", "detector"],
71 isCalibration=True,
72 )
73 linearizer = cT.PrerequisiteInput(
74 name='linearizer',
75 storageClass="Linearizer",
76 doc="Linearity correction calibration.",
77 dimensions=["instrument", "detector"],
78 isCalibration=True,
79 )
80 ptc = cT.PrerequisiteInput(
81 name="ptc",
82 doc="Input Photon Transfer Curve dataset",
83 storageClass="PhotonTransferCurveDataset",
84 dimensions=["instrument", "detector"],
85 isCalibration=True,
86 )
87 crosstalk = cT.PrerequisiteInput(
88 name="crosstalk",
89 doc="Input crosstalk object",
90 storageClass="CrosstalkCalib",
91 dimensions=["instrument", "detector"],
92 isCalibration=True,
93 )
94 defects = cT.PrerequisiteInput(
95 name='defects',
96 doc="Input defect tables.",
97 storageClass="Defects",
98 dimensions=["instrument", "detector"],
99 isCalibration=True,
100 )
101 bfKernel = cT.PrerequisiteInput(
102 name="bfk",
103 doc="Complete kernel + gain solutions.",
104 storageClass="BrighterFatterKernel",
105 dimensions=["instrument", "detector"],
106 isCalibration=True,
107 )
108 dark = cT.PrerequisiteInput(
109 name='dark',
110 doc="Input dark calibration.",
111 storageClass="ExposureF",
112 dimensions=["instrument", "detector"],
113 isCalibration=True,
114 )
115 flat = cT.PrerequisiteInput(
116 name="flat",
117 doc="Input flat calibration.",
118 storageClass="ExposureF",
119 dimensions=["instrument", "detector", "physical_filter"],
120 isCalibration=True,
121 )
122 outputExposure = cT.Output(
123 name='postISRCCD',
124 doc="Output ISR processed exposure.",
125 storageClass="Exposure",
126 dimensions=["instrument", "exposure", "detector"],
127 )
128 preInterpExposure = cT.Output(
129 name='preInterpISRCCD',
130 doc="Output ISR processed exposure, with pixels left uninterpolated.",
131 storageClass="ExposureF",
132 dimensions=["instrument", "exposure", "detector"],
133 )
134 outputBin1Exposure = cT.Output(
135 name="postIsrBin1",
136 doc="First binned image.",
137 storageClass="ExposureF",
138 dimensions=["instrument", "exposure", "detector"],
139 )
140 outputBin2Exposure = cT.Output(
141 name="postIsrBin2",
142 doc="Second binned image.",
143 storageClass="ExposureF",
144 dimensions=["instrument", "exposure", "detector"],
145 )
146
147 outputStatistics = cT.Output(
148 name="isrStatistics",
149 doc="Output of additional statistics table.",
150 storageClass="StructuredDataDict",
151 dimensions=["instrument", "exposure", "detector"],
152 )
153
154 def __init__(self, *, config=None):
155 super().__init__(config=config)
156
157 if config.doBootstrap:
158 del self.ptc
159 if config.doDiffNonLinearCorrection is not True:
160 del self.dnlLUT
161 if config.doBias is not True:
162 del self.bias
163 if config.doDeferredCharge is not True:
164 del self.deferredChargeCalib
165 if config.doLinearize is not True:
166 del self.linearizer
167 if not config.doCrosstalk:
168 del self.crosstalk
169 if config.doDefect is not True:
170 del self.defects
171 if config.doBrighterFatter is not True:
172 del self.bfKernel
173 if config.doDark is not True:
174 del self.dark
175 if config.doFlat is not True:
176 del self.flat
177
178 if config.doBinnedExposures is not True:
179 del self.outputBin1Exposure
180 del self.outputBin2Exposure
181 if config.doSaveInterpPixels is not True:
182 del self.preInterpExposure
183
184 if config.doCalculateStatistics is not True:
185 del self.outputStatistics
186
187
188class IsrTaskLSSTConfig(pipeBase.PipelineTaskConfig,
189 pipelineConnections=IsrTaskLSSTConnections):
190 """Configuration parameters for IsrTaskLSST.
191
192 Items are grouped in the order in which they are executed by the task.
193 """
194 expectWcs = pexConfig.Field(
195 dtype=bool,
196 default=True,
197 doc="Expect input science images to have a WCS (set False for e.g. spectrographs)."
198 )
199 qa = pexConfig.ConfigField(
200 dtype=isrQa.IsrQaConfig,
201 doc="QA related configuration options.",
202 )
203 doHeaderProvenance = pexConfig.Field(
204 dtype=bool,
205 default=True,
206 doc="Write calibration identifiers into output exposure header.",
207 )
208
209 # Calib checking configuration:
210 doRaiseOnCalibMismatch = pexConfig.Field(
211 dtype=bool,
212 default=False,
213 doc="Should IsrTaskLSST halt if exposure and calibration header values do not match?",
214 )
215 cameraKeywordsToCompare = pexConfig.ListField(
216 dtype=str,
217 doc="List of header keywords to compare between exposure and calibrations.",
218 default=[],
219 )
220
221 # Differential non-linearity correction.
222 doDiffNonLinearCorrection = pexConfig.Field(
223 dtype=bool,
224 doc="Do differential non-linearity correction?",
225 default=False,
226 )
227
228 doBootstrap = pexConfig.Field(
229 dtype=bool,
230 default=False,
231 doc="Is this task to be run in a ``bootstrap`` fashion that does not require "
232 "a PTC or full calibrations?",
233 )
234
235 doCheckUnprocessableData = pexConfig.Field(
236 dtype=bool,
237 default=True,
238 doc="Check if this image is completely unprocessable due to all bad amps.",
239 )
240
241 overscanCamera = pexConfig.ConfigField(
242 dtype=OverscanCameraConfig,
243 doc="Per-detector and per-amplifier overscan configurations.",
244 )
245
246 serialOverscanMedianShiftSigmaThreshold = pexConfig.Field(
247 dtype=float,
248 default=numpy.inf,
249 doc="Number of sigma difference from per-amp overscan median (as compared to PTC) to "
250 "check if an amp is in a different state than the baseline PTC calib and should "
251 "be marked BAD. Set to np.inf/np.nan to turn off overscan median checking.",
252 )
253
254 ampNoiseThreshold = pexConfig.Field(
255 dtype=float,
256 default=25.0,
257 doc="Maximum amplifier noise (e-) that is allowed before an amp is masked as bad. "
258 "Set to np.inf/np.nan to turn off noise checking.",
259 )
260
261 bssVoltageMinimum = pexConfig.Field(
262 dtype=float,
263 default=5.0,
264 doc="Minimum back-side bias voltage. Below this the detector is ``off`` and an "
265 "UnprocessableDataError will be logged. Check will be skipped if doCheckUnprocessableData "
266 "is False or if value is less than or equal to 0.",
267 )
268 bssVoltageKeyword = pexConfig.Field(
269 dtype=str,
270 default="BSSVBS",
271 doc="Back-side bias voltage header keyword. Only checked if doCheckUnprocessableData is True "
272 "and bssVoltageMinimum is greater than 0.",
273 )
274 hvBiasKeyword = pexConfig.Field(
275 dtype=str,
276 default="HVBIAS",
277 doc="Back-side bias voltage on/off header keyword. Only checked if doCheckUnprocessableData is True "
278 "and bssVoltageMinimum is greater than 0.",
279 )
280
281 # Amplifier to CCD assembly configuration.
282 doAssembleCcd = pexConfig.Field(
283 dtype=bool,
284 default=True,
285 doc="Assemble amp-level exposures into a ccd-level exposure?"
286 )
287 assembleCcd = pexConfig.ConfigurableField(
288 target=AssembleCcdTask,
289 doc="CCD assembly task.",
290 )
291
292 # Bias subtraction.
293 doBias = pexConfig.Field(
294 dtype=bool,
295 doc="Apply bias frame correction?",
296 default=True,
297 )
298
299 # Deferred charge correction.
300 doDeferredCharge = pexConfig.Field(
301 dtype=bool,
302 doc="Apply deferred charge correction?",
303 default=True,
304 )
305 deferredChargeCorrection = pexConfig.ConfigurableField(
306 target=DeferredChargeTask,
307 doc="Deferred charge correction task.",
308 )
309
310 # Linearization.
311 doLinearize = pexConfig.Field(
312 dtype=bool,
313 doc="Correct for nonlinearity of the detector's response?",
314 default=True,
315 )
316
317 # Gains.
318 doCorrectGains = pexConfig.Field(
319 dtype=bool,
320 doc="Apply temperature correction to the gains?",
321 default=False,
322 )
323 doApplyGains = pexConfig.Field(
324 dtype=bool,
325 doc="Apply gains to the image?",
326 default=True,
327 )
328
329 # Variance construction.
330 doVariance = pexConfig.Field(
331 dtype=bool,
332 doc="Calculate variance?",
333 default=True
334 )
335 maskNegativeVariance = pexConfig.Field(
336 dtype=bool,
337 doc="Mask pixels that claim a negative variance. This likely indicates a failure "
338 "in the measurement of the overscan at an edge due to the data falling off faster "
339 "than the overscan model can account for it.",
340 default=True,
341 )
342 negativeVarianceMaskName = pexConfig.Field(
343 dtype=str,
344 doc="Mask plane to use to mark pixels with negative variance, if `maskNegativeVariance` is True.",
345 default="BAD",
346 )
347 doSaturation = pexConfig.Field(
348 dtype=bool,
349 doc="Mask saturated pixels? NB: this is totally independent of the"
350 " interpolation option - this is ONLY setting the bits in the mask."
351 " To have them interpolated make sure doInterpolate=True and"
352 " maskListToInterpolate includes SAT.",
353 default=True,
354 )
355 saturatedMaskName = pexConfig.Field(
356 dtype=str,
357 doc="Name of mask plane to use in saturation detection and interpolation.",
358 default="SAT",
359 )
360 defaultSaturationSource = pexConfig.ChoiceField(
361 dtype=str,
362 doc="Source to retrieve default amp-level saturation values.",
363 allowed={
364 "NONE": "No default saturation values; only config overrides will be used.",
365 "CAMERAMODEL": "Use the default from the camera model (old defaults).",
366 "PTCTURNOFF": "Use the ptcTurnoff value as the saturation level.",
367 },
368 default="PTCTURNOFF",
369 )
370 doSuspect = pexConfig.Field(
371 dtype=bool,
372 doc="Mask suspect pixels?",
373 default=True,
374 )
375 suspectMaskName = pexConfig.Field(
376 dtype=str,
377 doc="Name of mask plane to use for suspect pixels.",
378 default="SUSPECT",
379 )
380 defaultSuspectSource = pexConfig.ChoiceField(
381 dtype=str,
382 doc="Source to retrieve default amp-level suspect values.",
383 allowed={
384 "NONE": "No default suspect values; only config overrides will be used.",
385 "CAMERAMODEL": "Use the default from the camera model (old defaults).",
386 "PTCTURNOFF": "Use the ptcTurnoff value as the suspect level.",
387 },
388 default="PTCTURNOFF",
389 )
390
391 # Crosstalk.
392 doCrosstalk = pexConfig.Field(
393 dtype=bool,
394 doc="Apply intra-CCD crosstalk correction?",
395 default=True,
396 )
397 crosstalk = pexConfig.ConfigurableField(
398 target=CrosstalkTask,
399 doc="Intra-CCD crosstalk correction.",
400 )
401
402 # Masking options.
403 doITLDipMask = pexConfig.Field(
404 dtype=bool,
405 doc="Apply ``ITL dip`` masking. The ``itlDipMaskPlane`` mask plane "
406 "will be added even if this configuration is False.",
407 default=True,
408 )
409 itlDipMaskPlanes = pexConfig.ListField(
410 dtype=str,
411 doc="Mask plane to use for ITL dip pixels.",
412 default=["SUSPECT", "ITL_DIP"],
413 )
414 doDefect = pexConfig.Field(
415 dtype=bool,
416 doc="Apply correction for CCD defects, e.g. hot pixels?",
417 default=True,
418 )
419 badAmps = pexConfig.ListField(
420 dtype=str,
421 doc="List of bad amps that should be masked as BAD in the defect code. "
422 "Value should be of form {detector_name}_{amp_name}, e.g. ``R42_S21_C07``. "
423 "Only used if doDefect is True.",
424 default=[],
425 )
426 doNanMasking = pexConfig.Field(
427 dtype=bool,
428 doc="Mask non-finite (NAN, inf) pixels. The UNMASKEDNAN mask plane "
429 "will be added even if this configuration is False.",
430 default=True,
431 )
432 doWidenSaturationTrails = pexConfig.Field(
433 dtype=bool,
434 doc="Widen bleed trails based on their width.",
435 default=False,
436 )
437 masking = pexConfig.ConfigurableField(
438 target=MaskingTask,
439 doc="Masking task."
440 )
441 doE2VEdgeBleedMask = pexConfig.Field(
442 dtype=bool,
443 doc="Mask flag-like edge bleeds from saturated columns "
444 "in E2V amplifiers.",
445 default=True,
446 )
447 e2vEdgeBleedSatMinArea = pexConfig.Field(
448 dtype=int,
449 doc="Minimum limit of saturated cores footprint area to apply edge"
450 "bleed masking in E2V amplifiers.",
451 default=10000,
452 )
453 e2vEdgeBleedSatMaxArea = pexConfig.Field(
454 dtype=int,
455 doc="Maximum limit of saturated cores footprint area to apply edge"
456 "bleed masking in E2V amplifiers.",
457 default=100000,
458 )
459 e2vEdgeBleedYMax = pexConfig.Field(
460 dtype=int,
461 doc="Height in pixels of edge bleed masking in E2V amplifiers (width"
462 "is the width of the amplifier).",
463 default=350,
464 )
465 doITLEdgeBleedMask = pexConfig.Field(
466 dtype=bool,
467 doc="Mask edge bleeds from saturated columns in ITL amplifiers.",
468 default=True,
469 )
470 doITLSatSagMask = pexConfig.Field(
471 dtype=bool,
472 doc="Mask columns presenting saturation sag.",
473 default=True,
474 )
475 itlEdgeBleedSatMinArea = pexConfig.Field(
476 dtype=int,
477 doc="Minimum limit for saturated cores footprint area.",
478 default=10000,
479 )
480 itlEdgeBleedSatMaxArea = pexConfig.Field(
481 dtype=int,
482 doc="Maximum limit for saturated cores footprint area.",
483 default=100000,
484 )
485 itlEdgeBleedThreshold = pexConfig.Field(
486 dtype=float,
487 doc="Sky background threshold for edge bleed detection.",
488 default=5000.,
489 )
490 itlEdgeBleedModelConstant = pexConfig.Field(
491 dtype=float,
492 doc="Constant in the edge bleed exponential decay model.",
493 default=0.02,
494 )
495
496 # Interpolation options.
497 doInterpolate = pexConfig.Field(
498 dtype=bool,
499 doc="Interpolate masked pixels?",
500 default=True,
501 )
502 maskListToInterpolate = pexConfig.ListField(
503 dtype=str,
504 doc="List of mask planes that should be interpolated.",
505 default=["SAT", "BAD", "UNMASKEDNAN"],
506 )
507 doSaveInterpPixels = pexConfig.Field(
508 dtype=bool,
509 doc="Save a copy of the pre-interpolated pixel values?",
510 default=False,
511 )
512 useLegacyInterp = pexConfig.Field(
513 dtype=bool,
514 doc="Use the legacy interpolation algorithm. If False use Gaussian Process.",
515 default=True,
516 )
517
518 # Amp offset correction.
519 doAmpOffset = pexConfig.Field(
520 doc="Calculate amp offset corrections?",
521 dtype=bool,
522 default=False,
523 )
524 ampOffset = pexConfig.ConfigurableField(
525 doc="Amp offset correction task.",
526 target=AmpOffsetTask,
527 )
528
529 # Initial masking options.
530 doSetBadRegions = pexConfig.Field(
531 dtype=bool,
532 doc="Should we set the level of all BAD patches of the chip to the chip's average value?",
533 default=True,
534 )
535
536 # Brighter-Fatter correction.
537 doBrighterFatter = pexConfig.Field(
538 dtype=bool,
539 doc="Apply the brighter-fatter correction?",
540 default=True,
541 )
542 brighterFatterLevel = pexConfig.ChoiceField(
543 dtype=str,
544 doc="The level at which to correct for brighter-fatter.",
545 allowed={
546 "AMP": "Every amplifier treated separately.",
547 "DETECTOR": "One kernel per detector.",
548 },
549 default="DETECTOR",
550 )
551 brighterFatterMaxIter = pexConfig.Field(
552 dtype=int,
553 doc="Maximum number of iterations for the brighter-fatter correction.",
554 default=10,
555 )
556 brighterFatterThreshold = pexConfig.Field(
557 dtype=float,
558 doc="Threshold used to stop iterating the brighter-fatter correction. It is the "
559 "absolute value of the difference between the current corrected image and the one "
560 "from the previous iteration summed over all the pixels.",
561 default=1000,
562 )
563 brighterFatterMaskListToInterpolate = pexConfig.ListField(
564 dtype=str,
565 doc="List of mask planes that should be interpolated over when applying the brighter-fatter "
566 "correction.",
567 default=["SAT", "BAD", "NO_DATA", "UNMASKEDNAN"],
568 )
569 brighterFatterMaskGrowSize = pexConfig.Field(
570 dtype=int,
571 doc="Number of pixels to grow the masks listed in config.brighterFatterMaskListToInterpolate "
572 "when brighter-fatter correction is applied.",
573 default=2,
574 )
575 brighterFatterFwhmForInterpolation = pexConfig.Field(
576 dtype=float,
577 doc="FWHM of PSF in arcseconds used for interpolation in brighter-fatter correction "
578 "(currently unused).",
579 default=1.0,
580 )
581 growSaturationFootprintSize = pexConfig.Field(
582 dtype=int,
583 doc="Number of pixels by which to grow the saturation footprints.",
584 default=1,
585 )
586
587 # Dark subtraction.
588 doDark = pexConfig.Field(
589 dtype=bool,
590 doc="Apply dark frame correction.",
591 default=True,
592 )
593
594 # Flat correction.
595 doFlat = pexConfig.Field(
596 dtype=bool,
597 doc="Apply flat field correction.",
598 default=True,
599 )
600 flatScalingType = pexConfig.ChoiceField(
601 dtype=str,
602 doc="The method for scaling the flat on the fly.",
603 default='USER',
604 allowed={
605 "USER": "Scale by flatUserScale",
606 "MEAN": "Scale by the inverse of the mean",
607 "MEDIAN": "Scale by the inverse of the median",
608 },
609 )
610 flatUserScale = pexConfig.Field(
611 dtype=float,
612 doc="If flatScalingType is 'USER' then scale flat by this amount; ignored otherwise.",
613 default=1.0,
614 )
615
616 # Calculate image quality statistics?
617 doStandardStatistics = pexConfig.Field(
618 dtype=bool,
619 doc="Should standard image quality statistics be calculated?",
620 default=True,
621 )
622 # Calculate additional statistics?
623 doCalculateStatistics = pexConfig.Field(
624 dtype=bool,
625 doc="Should additional ISR statistics be calculated?",
626 default=True,
627 )
628 isrStats = pexConfig.ConfigurableField(
629 target=IsrStatisticsTask,
630 doc="Task to calculate additional statistics.",
631 )
632
633 # Make binned images?
634 doBinnedExposures = pexConfig.Field(
635 dtype=bool,
636 doc="Should binned exposures be calculated?",
637 default=False,
638 )
639 binning = pexConfig.ConfigurableField(
640 target=BinExposureTask,
641 doc="Task to bin the exposure.",
642 )
643 binFactor1 = pexConfig.Field(
644 dtype=int,
645 doc="Binning factor for first binned exposure. This is intended for a finely binned output.",
646 default=8,
647 check=lambda x: x > 1,
648 )
649 binFactor2 = pexConfig.Field(
650 dtype=int,
651 doc="Binning factor for second binned exposure. This is intended for a coarsely binned output.",
652 default=64,
653 check=lambda x: x > 1,
654 )
655
656 def validate(self):
657 super().validate()
658
659 if self.doBootstrap:
660 # Additional checks in bootstrap (no PTC/gains) mode.
661 if self.doApplyGains:
662 raise ValueError("Cannot run task with doBootstrap=True and doApplyGains=True.")
663 if self.doCorrectGains:
664 raise ValueError("Cannot run task with doBootstrap=True and doCorrectGains=True.")
665 if self.doCrosstalk and self.crosstalk.doQuadraticCrosstalkCorrection:
666 raise ValueError("Cannot apply quadratic crosstalk correction with doBootstrap=True.")
667 if numpy.isfinite(self.serialOverscanMedianShiftSigmaThreshold):
668 raise ValueError("Cannot do amp overscan level checks with doBootstrap=True.")
669 if numpy.isfinite(self.ampNoiseThreshold):
670 raise ValueError("Cannot do amp noise thresholds with doBootstrap=True.")
671
672 if self.doITLEdgeBleedMask and not self.doSaturation:
673 raise ValueError("Cannot do ITL edge bleed masking when doSaturation=False.")
674 if self.doE2VEdgeBleedMask and not self.doSaturation:
675 raise ValueError("Cannot do e2v edge bleed masking when doSaturation=False.")
676
677 def setDefaults(self):
678 super().setDefaults()
679
680
681class IsrTaskLSST(pipeBase.PipelineTask):
682 ConfigClass = IsrTaskLSSTConfig
683 _DefaultName = "isrLSST"
684
685 def __init__(self, **kwargs):
686 super().__init__(**kwargs)
687 self.makeSubtask("assembleCcd")
688 self.makeSubtask("deferredChargeCorrection")
689 self.makeSubtask("crosstalk")
690 self.makeSubtask("masking")
691 self.makeSubtask("isrStats")
692 self.makeSubtask("ampOffset")
693 self.makeSubtask("binning")
694
695 def runQuantum(self, butlerQC, inputRefs, outputRefs):
696
697 inputs = butlerQC.get(inputRefs)
698 self.validateInput(inputs)
699
700 if self.config.doHeaderProvenance:
701 # Add calibration provenanace info to header.
702 exposureMetadata = inputs['ccdExposure'].metadata
703 for inputName in sorted(list(inputs.keys())):
704 reference = getattr(inputRefs, inputName, None)
705 if reference is not None and hasattr(reference, "run"):
706 runKey = f"LSST CALIB RUN {inputName.upper()}"
707 runValue = reference.run
708 idKey = f"LSST CALIB UUID {inputName.upper()}"
709 idValue = str(reference.id)
710 dateKey = f"LSST CALIB DATE {inputName.upper()}"
711 dateValue = self.extractCalibDate(inputs[inputName])
712 if dateValue != "Unknown Unknown":
713 butlerQC.add_additional_provenance(reference, {"calib date": dateValue})
714
715 exposureMetadata[runKey] = runValue
716 exposureMetadata[idKey] = idValue
717 exposureMetadata[dateKey] = dateValue
718
719 outputs = self.run(**inputs)
720 butlerQC.put(outputs, outputRefs)
721
722 def validateInput(self, inputs):
723 """
724 This is a check that all the inputs required by the config
725 are available.
726 """
727
728 inputMap = {'dnlLUT': self.config.doDiffNonLinearCorrection,
729 'bias': self.config.doBias,
730 'deferredChargeCalib': self.config.doDeferredCharge,
731 'linearizer': self.config.doLinearize,
732 'ptc': self.config.doApplyGains,
733 'crosstalk': self.config.doCrosstalk,
734 'defects': self.config.doDefect,
735 'bfKernel': self.config.doBrighterFatter,
736 'dark': self.config.doDark,
737 }
738
739 for calibrationFile, configValue in inputMap.items():
740 if configValue and inputs[calibrationFile] is None:
741 raise RuntimeError("Must supply ", calibrationFile)
742
743 def diffNonLinearCorrection(self, ccdExposure, dnlLUT, **kwargs):
744 # TODO DM 36636
745 # isrFunctions.diffNonLinearCorrection
746 pass
747
748 def maskFullAmplifiers(self, ccdExposure, detector, defects, gains=None):
749 """
750 Check for fully masked bad amplifiers and mask them.
751
752 This includes defects which cover full amplifiers, as well
753 as amplifiers with nan gain values which should be used
754 if self.config.doApplyGains=True.
755
756 Full defect masking happens later to allow for defects which
757 cross amplifier boundaries.
758
759 Parameters
760 ----------
761 ccdExposure : `lsst.afw.image.Exposure`
762 Input exposure to be masked.
763 detector : `lsst.afw.cameraGeom.Detector`
764 Detector object.
765 defects : `lsst.ip.isr.Defects`
766 List of defects. Used to determine if an entire
767 amplifier is bad.
768 gains : `dict` [`str`, `float`], optional
769 Dictionary of gains to check if
770 self.config.doApplyGains=True.
771
772 Returns
773 -------
774 badAmpDict : `str`[`bool`]
775 Dictionary of amplifiers, keyed by name, value is True if
776 amplifier is fully masked.
777 """
778 badAmpDict = {}
779
780 maskedImage = ccdExposure.getMaskedImage()
781
782 for amp in detector:
783 ampName = amp.getName()
784 badAmpDict[ampName] = False
785
786 # Check if entire amp region is defined as a defect
787 # NB: need to use amp.getBBox() for correct comparison with current
788 # defects definition.
789 if defects is not None:
790 badAmpDict[ampName] = bool(sum([v.getBBox().contains(amp.getBBox()) for v in defects]))
791
792 if badAmpDict[ampName]:
793 self.log.warning("Amplifier %s is bad (completely covered with defects)", ampName)
794
795 if gains is not None and self.config.doApplyGains:
796 if not math.isfinite(gains[ampName]):
797 badAmpDict[ampName] = True
798
799 self.log.warning("Amplifier %s is bad (non-finite gain)", ampName)
800
801 # In the case of a bad amp, we will set mask to "BAD"
802 # (here use amp.getRawBBox() for correct association with pixels in
803 # current ccdExposure).
804 if badAmpDict[ampName]:
805 dataView = afwImage.MaskedImageF(maskedImage, amp.getRawBBox(),
806 afwImage.PARENT)
807 maskView = dataView.getMask()
808 maskView |= maskView.getPlaneBitMask("BAD")
809 del maskView
810
811 return badAmpDict
812
813 def checkAllBadAmps(self, badAmpDict, detector):
814 """Check if all amps are marked as bad.
815
816 Parameters
817 ----------
818 badAmpDict : `str`[`bool`]
819 Dictionary of amplifiers, keyed by name, value is True if
820 amplifier is fully masked.
821 detector : `lsst.afw.cameraGeom.Detector`
822 Detector object.
823
824 Raises
825 ------
826 UnprocessableDataError if all amps are bad and doCheckUnprocessableData
827 configuration is True.
828 """
829 if not self.config.doCheckUnprocessableData:
830 return
831
832 for amp in detector:
833 if not badAmpDict.get(amp.getName(), False):
834 return
835
836 raise UnprocessableDataError(f"All amps in the exposure {detector.getName()} are bad; skipping ISR.")
837
838 def checkAmpOverscanLevel(self, badAmpDict, exposure, ptc):
839 """Check if the amplifier overscan levels have changed.
840
841 Any amplifier that has an overscan median level that has changed
842 significantly will be masked as BAD and added to toe badAmpDict.
843
844 Parameters
845 ----------
846 badAmpDict : `str` [`bool`]
847 Dictionary of amplifiers, keyed by name, value is True if
848 amplifier is fully masked.
849 exposure : `lsst.afw.image.Exposure`
850 Input exposure to be masked (untrimmed).
851 ptc : `lsst.ip.isr.PhotonTransferCurveDataset`
852 PTC dataset with gains/read noises.
853
854 Returns
855 -------
856 badAmpDict : `str`[`bool`]
857 Dictionary of amplifiers, keyed by name.
858
859 """
860 if isTrimmedExposure(exposure):
861 raise RuntimeError("checkAmpOverscanLevel must be run on an untrimmed exposure.")
862
863 # We want to consolidate all the amps into one warning if necessary.
864 # This config should not be set to a finite threshold if the necessary
865 # data is not in the PTC.
866 missingWarnString = "No PTC overscan information for amplifier "
867 missingWarnFlag = False
868 for amp in exposure.getDetector():
869 ampName = amp.getName()
870
871 if not numpy.isfinite(ptc.overscanMedian[ampName]) or \
872 not numpy.isfinite(ptc.overscanMedianSigma[ampName]):
873 missingWarnString += f"{ampName},"
874 missingWarnFlag = True
875 else:
876 key = f"LSST ISR OVERSCAN SERIAL MEDIAN {ampName}"
877 # If it is missing, just return the PTC value and it
878 # will be skipped.
879 overscanLevel = exposure.metadata.get(key, ptc.overscanMedian[ampName])
880 pull = (overscanLevel - ptc.overscanMedian[ampName])/ptc.overscanMedianSigma[ampName]
881 if numpy.abs(pull) > self.config.serialOverscanMedianShiftSigmaThreshold:
882 self.log.warning(
883 "Amplifier %s has an overscan level that is %.2f sigma from the expected level; "
884 "masking it as BAD.",
885 ampName,
886 pull,
887 )
888
889 badAmpDict[ampName] = True
890 exposure.mask[amp.getRawBBox()] |= exposure.mask.getPlaneBitMask("BAD")
891
892 if missingWarnFlag:
893 self.log.warning(missingWarnString)
894
895 return badAmpDict
896
897 def checkAmpNoise(self, badAmpDict, exposure, ptc):
898 """Check if amplifier noise levels are above threshold.
899
900 Any amplifier that is above the noise level will be masked as BAD
901 and added to the badAmpDict.
902
903 Parameters
904 ----------
905 badAmpDict : `str` [`bool`]
906 Dictionary of amplifiers, keyed by name, value is True if
907 amplifier is fully masked.
908 exposure : `lsst.afw.image.Exposure`
909 Input exposure to be masked (untrimmed).
910 ptc : `lsst.ip.isr.PhotonTransferCurveDataset`
911 PTC dataset with gains/read noises.
912
913 Returns
914 -------
915 badAmpDict : `str`[`bool`]
916 Dictionary of amplifiers, keyed by name.
917 """
918
919 if isTrimmedExposure(exposure):
920 raise RuntimeError("checkAmpNoise must be run on an untrimmed exposure.")
921
922 for amp in exposure.getDetector():
923 ampName = amp.getName()
924
925 doMask = False
926 if ptc.noise[ampName] > self.config.ampNoiseThreshold:
927 self.log.info(
928 "Amplifier %s has a PTC noise level of %.2f e-, above threshold.",
929 ampName,
930 ptc.noise[ampName],
931 )
932 doMask = True
933 else:
934 key = f"LSST ISR OVERSCAN RESIDUAL SERIAL STDEV {ampName}"
935 overscanNoise = exposure.metadata.get(key, numpy.nan)
936 if overscanNoise * ptc.gain[ampName] > self.config.ampNoiseThreshold:
937 self.log.warning(
938 "Amplifier %s has an overscan read noise level of %.2f e-, above threshold.",
939 ampName,
940 overscanNoise * ptc.gain[ampName],
941 )
942 doMask = True
943
944 if doMask:
945 badAmpDict[ampName] = True
946
947 exposure.mask[amp.getRawBBox()] |= exposure.mask.getPlaneBitMask("BAD")
948
949 return badAmpDict
950
951 def maskSaturatedPixels(self, badAmpDict, ccdExposure, detector, detectorConfig, ptc=None):
952 """
953 Mask SATURATED and SUSPECT pixels and check if any amplifiers
954 are fully masked.
955
956 Parameters
957 ----------
958 badAmpDict : `str` [`bool`]
959 Dictionary of amplifiers, keyed by name, value is True if
960 amplifier is fully masked.
961 ccdExposure : `lsst.afw.image.Exposure`
962 Input exposure to be masked.
963 detector : `lsst.afw.cameraGeom.Detector`
964 Detector object.
965 defects : `lsst.ip.isr.Defects`
966 List of defects. Used to determine if an entire
967 amplifier is bad.
968 detectorConfig : `lsst.ip.isr.OverscanDetectorConfig`
969 Per-amplifier configurations.
970 ptc : `lsst.ip.isr.PhotonTransferCurveDataset`, optional
971 PTC dataset (used if configured to use PTCTURNOFF).
972
973 Returns
974 -------
975 badAmpDict : `str`[`bool`]
976 Dictionary of amplifiers, keyed by name.
977 """
978 maskedImage = ccdExposure.getMaskedImage()
979
980 metadata = ccdExposure.metadata
981
982 if self.config.doSaturation and self.config.defaultSaturationSource == "PTCTURNOFF" and ptc is None:
983 raise RuntimeError("Must provide ptc if using PTCTURNOFF as saturation source.")
984 if self.config.doSuspect and self.config.defaultSuspectSource == "PTCTURNOFF" and ptc is None:
985 raise RuntimeError("Must provide ptc if using PTCTURNOFF as suspect source.")
986
987 for amp in detector:
988 ampName = amp.getName()
989
990 ampConfig = detectorConfig.getOverscanAmpConfig(amp)
991
992 if badAmpDict[ampName]:
993 # No need to check fully bad amplifiers.
994 continue
995
996 # Mask saturated and suspect pixels.
997 limits = {}
998 if self.config.doSaturation:
999 if self.config.defaultSaturationSource == "PTCTURNOFF":
1000 limits.update({self.config.saturatedMaskName: ptc.ptcTurnoff[amp.getName()]})
1001 elif self.config.defaultSaturationSource == "CAMERAMODEL":
1002 # Set to the default from the camera model.
1003 limits.update({self.config.saturatedMaskName: amp.getSaturation()})
1004 elif self.config.defaultSaturationSource == "NONE":
1005 limits.update({self.config.saturatedMaskName: numpy.inf})
1006
1007 # And update if it is set in the config.
1008 if math.isfinite(ampConfig.saturation):
1009 limits.update({self.config.saturatedMaskName: ampConfig.saturation})
1010 metadata[f"LSST ISR SATURATION LEVEL {ampName}"] = limits[self.config.saturatedMaskName]
1011
1012 if self.config.doSuspect:
1013 if self.config.defaultSuspectSource == "PTCTURNOFF":
1014 limits.update({self.config.suspectMaskName: ptc.ptcTurnoff[amp.getName()]})
1015 elif self.config.defaultSuspectSource == "CAMERAMODEL":
1016 # Set to the default from the camera model.
1017 limits.update({self.config.suspectMaskName: amp.getSuspectLevel()})
1018 elif self.config.defaultSuspectSource == "NONE":
1019 limits.update({self.config.suspectMaskName: numpy.inf})
1020
1021 # And update if it set in the config.
1022 if math.isfinite(ampConfig.suspectLevel):
1023 limits.update({self.config.suspectMaskName: ampConfig.suspectLevel})
1024 metadata[f"LSST ISR SUSPECT LEVEL {ampName}"] = limits[self.config.suspectMaskName]
1025
1026 for maskName, maskThreshold in limits.items():
1027 if not math.isnan(maskThreshold):
1028 dataView = maskedImage.Factory(maskedImage, amp.getRawBBox())
1029 toMask = (dataView.image.array >= maskThreshold)
1030 dataView.mask.array[toMask] |= dataView.mask.getPlaneBitMask(maskName)
1031
1032 # Determine if we've fully masked this amplifier with SUSPECT and
1033 # SAT pixels.
1034 maskView = afwImage.Mask(maskedImage.getMask(), amp.getRawDataBBox(),
1035 afwImage.PARENT)
1036 maskVal = maskView.getPlaneBitMask([self.config.saturatedMaskName,
1037 self.config.suspectMaskName])
1038 if numpy.all(maskView.getArray() & maskVal > 0):
1039 self.log.warning("Amplifier %s is bad (completely SATURATED or SUSPECT)", ampName)
1040 badAmpDict[ampName] = True
1041 maskView |= maskView.getPlaneBitMask("BAD")
1042
1043 return badAmpDict
1044
1045 def maskITLSatEdgesAndColumns(self, exposure, badAmpDict):
1046
1047 # The following steps will rely on the footprint of saturated
1048 # cores with large areas.
1049 thresh = afwDetection.Threshold(exposure.mask.getPlaneBitMask("SAT"),
1050 afwDetection.Threshold.BITMASK
1051 )
1052 fpList = afwDetection.FootprintSet(exposure.mask, thresh).getFootprints()
1053
1054 satAreas = numpy.asarray([fp.getArea() for fp in fpList])
1055 largeAreas, = numpy.where((satAreas >= self.config.itlEdgeBleedSatMinArea)
1056 & (satAreas < self.config.itlEdgeBleedSatMaxArea))
1057
1058 for largeAreasIndex in largeAreas:
1059
1060 fpCore = fpList[largeAreasIndex]
1061
1062 # Edge bleed masking
1063 if self.config.doITLEdgeBleedMask:
1064 isrFunctions.maskITLEdgeBleed(ccdExposure=exposure,
1065 badAmpDict=badAmpDict,
1066 fpCore=fpCore,
1067 itlEdgeBleedSatMinArea=self.config.itlEdgeBleedSatMinArea,
1068 itlEdgeBleedSatMaxArea=self.config.itlEdgeBleedSatMaxArea,
1069 itlEdgeBleedThreshold=self.config.itlEdgeBleedThreshold,
1070 itlEdgeBleedModelConstant=self.config.itlEdgeBleedModelConstant,
1071 saturatedMaskName=self.config.saturatedMaskName,
1072 log=self.log
1073 )
1074 if self.config.doITLSatSagMask:
1075 isrFunctions.maskITLSatSag(ccdExposure=exposure, fpCore=fpCore,
1076 saturatedMaskName=self.config.saturatedMaskName)
1077
1078 def overscanCorrection(self, mode, detectorConfig, detector, badAmpDict, ccdExposure):
1079 """Apply serial overscan correction in place to all amps.
1080
1081 The actual overscan subtraction is performed by the
1082 `lsst.ip.isr.overscan.OverscanTask`, which is called here.
1083
1084 Parameters
1085 ----------
1086 mode : `str`
1087 Must be `SERIAL` or `PARALLEL`.
1088 detectorConfig : `lsst.ip.isr.OverscanDetectorConfig`
1089 Per-amplifier configurations.
1090 detector : `lsst.afw.cameraGeom.Detector`
1091 Detector object.
1092 badAmpDict : `dict`
1093 Dictionary of amp name to whether it is a bad amp.
1094 ccdExposure : `lsst.afw.image.Exposure`
1095 Exposure to have overscan correction performed.
1096
1097 Returns
1098 -------
1099 overscans : `list` [`lsst.pipe.base.Struct` or None]
1100 Overscan measurements (always in adu).
1101 Each result struct has components:
1102
1103 ``imageFit``
1104 Value or fit subtracted from the amplifier image data.
1105 (scalar or `lsst.afw.image.Image`)
1106 ``overscanFit``
1107 Value or fit subtracted from the overscan image data.
1108 (scalar or `lsst.afw.image.Image`)
1109 ``overscanImage``
1110 Image of the overscan region with the overscan
1111 correction applied. This quantity is used to estimate
1112 the amplifier read noise empirically.
1113 (`lsst.afw.image.Image`)
1114 ``overscanMean``
1115 Mean overscan fit value. (`float`)
1116 ``overscanMedian``
1117 Median overscan fit value. (`float`)
1118 ``overscanSigma``
1119 Clipped standard deviation of the overscan fit. (`float`)
1120 ``residualMean``
1121 Mean of the overscan after fit subtraction. (`float`)
1122 ``residualMedian``
1123 Median of the overscan after fit subtraction. (`float`)
1124 ``residualSigma``
1125 Clipped standard deviation of the overscan after fit
1126 subtraction. (`float`)
1127
1128 See Also
1129 --------
1130 lsst.ip.isr.overscan.OverscanTask
1131 """
1132 if mode not in ["SERIAL", "PARALLEL"]:
1133 raise ValueError("Mode must be SERIAL or PARALLEL")
1134
1135 # This returns a list in amp order, with None for uncorrected amps.
1136 overscans = []
1137
1138 for i, amp in enumerate(detector):
1139 ampName = amp.getName()
1140
1141 ampConfig = detectorConfig.getOverscanAmpConfig(amp)
1142
1143 if mode == "SERIAL" and not ampConfig.doSerialOverscan:
1144 self.log.debug(
1145 "ISR_OSCAN: Amplifier %s/%s configured to skip serial overscan.",
1146 detector.getName(),
1147 ampName,
1148 )
1149 results = None
1150 elif mode == "PARALLEL" and not ampConfig.doParallelOverscan:
1151 self.log.debug(
1152 "ISR_OSCAN: Amplifier %s configured to skip parallel overscan.",
1153 detector.getName(),
1154 ampName,
1155 )
1156 results = None
1157 elif badAmpDict[ampName] or not ccdExposure.getBBox().contains(amp.getBBox()):
1158 results = None
1159 else:
1160 # This check is to confirm that we are not trying to run
1161 # overscan on an already trimmed image.
1162 if isTrimmedExposure(ccdExposure):
1163 self.log.warning(
1164 "ISR_OSCAN: No overscan region for amp %s. Not performing overscan correction.",
1165 ampName,
1166 )
1167 results = None
1168 else:
1169 if mode == "SERIAL":
1170 # We need to set up the subtask here with a custom
1171 # configuration.
1172 serialOverscan = SerialOverscanCorrectionTask(config=ampConfig.serialOverscanConfig)
1173 results = serialOverscan.run(ccdExposure, amp)
1174 else:
1175 config = ampConfig.parallelOverscanConfig
1176 parallelOverscan = ParallelOverscanCorrectionTask(
1177 config=config,
1178 )
1179
1180 metadata = ccdExposure.metadata
1181
1182 # We need to know the saturation level that was used
1183 # for the parallel overscan masking. If it isn't set
1184 # then the configured parallelOverscanSaturationLevel
1185 # will be used instead (assuming
1186 # doParallelOverscanSaturation is True). Note that
1187 # this will have the correct units (adu or electron)
1188 # depending on whether the gain has been applied.
1189 if self.config.doSaturation:
1190 saturationLevel = metadata[f"LSST ISR SATURATION LEVEL {amp.getName()}"]
1191 saturationLevel *= config.parallelOverscanSaturationLevelAdjustmentFactor
1192 else:
1193 saturationLevel = config.parallelOverscanSaturationLevel
1194 if ccdExposure.metadata["LSST ISR UNITS"] == "electron":
1195 # Need to convert to electron from adu.
1196 saturationLevel *= metadata[f"LSST ISR GAIN {amp.getName()}"]
1197
1198 self.log.debug(
1199 "Using saturation level of %.2f for parallel overscan amp %s",
1200 saturationLevel,
1201 amp.getName(),
1202 )
1203
1204 parallelOverscan.maskParallelOverscanAmp(
1205 ccdExposure,
1206 amp,
1207 saturationLevel=saturationLevel,
1208 )
1209
1210 results = parallelOverscan.run(ccdExposure, amp)
1211
1212 metadata = ccdExposure.metadata
1213 keyBase = "LSST ISR OVERSCAN"
1214
1215 # The overscan is always in adu for the serial mode,
1216 # but, it may be electron in the parallel mode if
1217 # doApplyGains==True. If doApplyGains==True, then the
1218 # gains are applied to the untrimmed image, so the
1219 # overscan statistics units here will always match the
1220 # units of the image at this point.
1221 metadata[f"{keyBase} {mode} UNITS"] = ccdExposure.metadata["LSST ISR UNITS"]
1222 metadata[f"{keyBase} {mode} MEAN {ampName}"] = results.overscanMean
1223 metadata[f"{keyBase} {mode} MEDIAN {ampName}"] = results.overscanMedian
1224 metadata[f"{keyBase} {mode} STDEV {ampName}"] = results.overscanSigma
1225
1226 metadata[f"{keyBase} RESIDUAL {mode} MEAN {ampName}"] = results.residualMean
1227 metadata[f"{keyBase} RESIDUAL {mode} MEDIAN {ampName}"] = results.residualMedian
1228 metadata[f"{keyBase} RESIDUAL {mode} STDEV {ampName}"] = results.residualSigma
1229
1230 overscans.append(results)
1231
1232 # Question: should this be finer grained?
1233 ccdExposure.metadata.set("OVERSCAN", "Overscan corrected")
1234
1235 return overscans
1236
1237 def correctGains(self, exposure, ptc, gains):
1238 # TODO DM 36639
1239 gains = []
1240 readNoise = []
1241
1242 return gains, readNoise
1243
1244 def maskNegativeVariance(self, exposure):
1245 """Identify and mask pixels with negative variance values.
1246
1247 Parameters
1248 ----------
1249 exposure : `lsst.afw.image.Exposure`
1250 Exposure to process.
1251
1252 See Also
1253 --------
1254 lsst.ip.isr.isrFunctions.updateVariance
1255 """
1256 maskPlane = exposure.getMask().getPlaneBitMask(self.config.negativeVarianceMaskName)
1257 bad = numpy.where(exposure.getVariance().getArray() <= 0.0)
1258 exposure.mask.array[bad] |= maskPlane
1259
1260 def addVariancePlane(self, exposure, detector):
1261 """Add the variance plane to the image.
1262
1263 The gain and read noise per amp must have been set in the
1264 exposure metadata as ``LSST ISR GAIN ampName`` and
1265 ``LSST ISR READNOISE ampName`` with the units of the image.
1266 Unit conversions for the variance plane will be done as
1267 necessary based on the exposure units.
1268
1269 The units of the variance plane will always be of the same
1270 type as the units of the input image itself
1271 (``LSST ISR UNITS``^2).
1272
1273 Parameters
1274 ----------
1275 exposure : `lsst.afw.image.Exposure`
1276 The exposure to add the variance plane.
1277 detector : `lsst.afw.cameraGeom.Detector`
1278 Detector with geometry info.
1279 """
1280 # NOTE: this will fail if the exposure is not trimmed.
1281 if not isTrimmedExposure(exposure):
1282 raise RuntimeError("Exposure must be trimmed to add variance plane.")
1283
1284 isElectrons = (exposure.metadata["LSST ISR UNITS"] == "electron")
1285
1286 for amp in detector:
1287 if exposure.getBBox().contains(amp.getBBox()):
1288 self.log.debug("Constructing variance map for amplifer %s.", amp.getName())
1289 ampExposure = exposure.Factory(exposure, amp.getBBox())
1290
1291 # The effective gain is 1.0 if we are in electron units.
1292 # The metadata read noise is in the same units as the image.
1293 gain = exposure.metadata[f"LSST ISR GAIN {amp.getName()}"] if not isElectrons else 1.0
1294 readNoise = exposure.metadata[f"LSST ISR READNOISE {amp.getName()}"]
1295
1296 isrFunctions.updateVariance(
1297 maskedImage=ampExposure.maskedImage,
1298 gain=gain,
1299 readNoise=readNoise,
1300 replace=False,
1301 )
1302
1303 if self.config.qa is not None and self.config.qa.saveStats is True:
1304 qaStats = afwMath.makeStatistics(ampExposure.getVariance(),
1305 afwMath.MEDIAN | afwMath.STDEVCLIP)
1306 self.log.debug(" Variance stats for amplifer %s: %f +/- %f.",
1307 amp.getName(), qaStats.getValue(afwMath.MEDIAN),
1308 qaStats.getValue(afwMath.STDEVCLIP))
1309
1310 if self.config.maskNegativeVariance:
1311 self.maskNegativeVariance(exposure)
1312
1313 def maskDefects(self, exposure, defectBaseList):
1314 """Mask defects using mask plane "BAD", in place.
1315
1316 Parameters
1317 ----------
1318 exposure : `lsst.afw.image.Exposure`
1319 Exposure to process.
1320
1321 defectBaseList : defect-type
1322 List of defects to mask. Can be of type `lsst.ip.isr.Defects`
1323 or `list` of `lsst.afw.image.DefectBase`.
1324 """
1325 maskedImage = exposure.getMaskedImage()
1326 if not isinstance(defectBaseList, Defects):
1327 # Promotes DefectBase to Defect
1328 defectList = Defects(defectBaseList)
1329 else:
1330 defectList = defectBaseList
1331 defectList.maskPixels(maskedImage, maskName="BAD")
1332
1333 if len(self.config.badAmps) == 0:
1334 return
1335
1336 detector = exposure.getDetector()
1337 mask = maskedImage.mask
1338 for badAmp in self.config.badAmps:
1339 if badAmp.startswith(detector.getName()):
1340 # Split on the full detector name plus _, which
1341 # gives us an empty string and the amp name.
1342 ampName = badAmp.split(detector.getName() + "_")[-1]
1343 self.log.info("Masking amplifier %s as bad via config.", ampName)
1344 mask[detector[ampName].getBBox()].array[:, :] |= mask.getPlaneBitMask("BAD")
1345
1346 def maskEdges(self, exposure, numEdgePixels=0, maskPlane="SUSPECT", level='DETECTOR'):
1347 """Mask edge pixels with applicable mask plane.
1348
1349 Parameters
1350 ----------
1351 exposure : `lsst.afw.image.Exposure`
1352 Exposure to process.
1353 numEdgePixels : `int`, optional
1354 Number of edge pixels to mask.
1355 maskPlane : `str`, optional
1356 Mask plane name to use.
1357 level : `str`, optional
1358 Level at which to mask edges.
1359 """
1360 maskedImage = exposure.getMaskedImage()
1361 maskBitMask = maskedImage.getMask().getPlaneBitMask(maskPlane)
1362
1363 if numEdgePixels > 0:
1364 if level == 'DETECTOR':
1365 boxes = [maskedImage.getBBox()]
1366 elif level == 'AMP':
1367 boxes = [amp.getBBox() for amp in exposure.getDetector()]
1368
1369 for box in boxes:
1370 # This makes a bbox numEdgeSuspect pixels smaller than the
1371 # image on each side
1372 subImage = maskedImage[box]
1373 box.grow(-numEdgePixels)
1374 # Mask pixels outside box
1375 SourceDetectionTask.setEdgeBits(
1376 subImage,
1377 box,
1378 maskBitMask)
1379
1380 def maskNan(self, exposure):
1381 """Mask NaNs using mask plane "UNMASKEDNAN", in place.
1382
1383 Parameters
1384 ----------
1385 exposure : `lsst.afw.image.Exposure`
1386 Exposure to process.
1387
1388 Notes
1389 -----
1390 We mask over all non-finite values (NaN, inf), including those
1391 that are masked with other bits (because those may or may not be
1392 interpolated over later, and we want to remove all NaN/infs).
1393 Despite this behaviour, the "UNMASKEDNAN" mask plane is used to
1394 preserve the historical name.
1395 """
1396 maskedImage = exposure.getMaskedImage()
1397
1398 # Find and mask NaNs
1399 maskVal = maskedImage.getMask().getPlaneBitMask("UNMASKEDNAN")
1400 numNans = maskNans(maskedImage, maskVal)
1401 self.metadata["NUMNANS"] = numNans
1402 if numNans > 0:
1403 self.log.warning("There were %d unmasked NaNs.", numNans)
1404
1405 def setBadRegions(self, exposure):
1406 """Set bad regions from large contiguous regions.
1407
1408 Parameters
1409 ----------
1410 exposure : `lsst.afw.Exposure`
1411 Exposure to set bad regions.
1412
1413 Notes
1414 -----
1415 Reset and interpolate bad pixels.
1416
1417 Large contiguous bad regions (which should have the BAD mask
1418 bit set) should have their values set to the image median.
1419 This group should include defects and bad amplifiers. As the
1420 area covered by these defects are large, there's little
1421 reason to expect that interpolation would provide a more
1422 useful value.
1423
1424 Smaller defects can be safely interpolated after the larger
1425 regions have had their pixel values reset. This ensures
1426 that the remaining defects adjacent to bad amplifiers (as an
1427 example) do not attempt to interpolate extreme values.
1428 """
1429 badPixelCount, badPixelValue = isrFunctions.setBadRegions(exposure)
1430 if badPixelCount > 0:
1431 self.log.info("Set %d BAD pixels to %f.", badPixelCount, badPixelValue)
1432
1433 @contextmanager
1434 def flatContext(self, exp, flat, dark=None):
1435 """Context manager that applies and removes flats and darks,
1436 if the task is configured to apply them.
1437
1438 Parameters
1439 ----------
1440 exp : `lsst.afw.image.Exposure`
1441 Exposure to process.
1442 flat : `lsst.afw.image.Exposure`
1443 Flat exposure the same size as ``exp``.
1444 dark : `lsst.afw.image.Exposure`, optional
1445 Dark exposure the same size as ``exp``.
1446
1447 Yields
1448 ------
1449 exp : `lsst.afw.image.Exposure`
1450 The flat and dark corrected exposure.
1451 """
1452 if self.config.doDark and dark is not None:
1453 self.darkCorrection(exp, dark)
1454 if self.config.doFlat and flat is not None:
1455 self.flatCorrection(exp, flat)
1456 try:
1457 yield exp
1458 finally:
1459 if self.config.doFlat and flat is not None:
1460 self.flatCorrection(exp, flat, invert=True)
1461 if self.config.doDark and dark is not None:
1462 self.darkCorrection(exp, dark, invert=True)
1463
1464 def getBrighterFatterKernel(self, detector, bfKernel):
1465 detName = detector.getName()
1466
1467 # This is expected to be a dictionary of amp-wise gains.
1468 bfGains = bfKernel.gain
1469 if bfKernel.level == 'DETECTOR':
1470 if detName in bfKernel.detKernels:
1471 bfKernelOut = bfKernel.detKernels[detName]
1472 return bfKernelOut, bfGains
1473 else:
1474 raise RuntimeError("Failed to extract kernel from new-style BF kernel.")
1475 elif bfKernel.level == 'AMP':
1476 self.log.info("Making DETECTOR level kernel from AMP based brighter "
1477 "fatter kernels.")
1478 bfKernel.makeDetectorKernelFromAmpwiseKernels(detName)
1479 bfKernelOut = bfKernel.detKernels[detName]
1480 return bfKernelOut, bfGains
1481
1482 def applyBrighterFatterCorrection(self, ccdExposure, flat, dark, bfKernel, brighterFatterApplyGain,
1483 bfGains):
1484 """Apply a brighter fatter correction to the image using the
1485 method defined in Coulton et al. 2019.
1486
1487 Note that this correction requires that the image is in units
1488 electrons.
1489
1490 Parameters
1491 ----------
1492 ccdExposure : `lsst.afw.image.Exposure`
1493 Exposure to process.
1494 flat : `lsst.afw.image.Exposure`
1495 Flat exposure the same size as ``exp``.
1496 dark : `lsst.afw.image.Exposure`, optional
1497 Dark exposure the same size as ``exp``.
1498 bfKernel : `lsst.ip.isr.BrighterFatterKernel`
1499 The brighter-fatter kernel.
1500 brighterFatterApplyGain : `bool`
1501 Apply the gain to convert the image to electrons?
1502 bfGains : `dict`
1503 The gains to use if brighterFatterApplyGain = True.
1504
1505 Yields
1506 ------
1507 exp : `lsst.afw.image.Exposure`
1508 The flat and dark corrected exposure.
1509 """
1510 interpExp = ccdExposure.clone()
1511
1512 # We need to interpolate before we do B-F. Note that
1513 # brighterFatterFwhmForInterpolation is currently unused.
1514 isrFunctions.interpolateFromMask(
1515 maskedImage=interpExp.getMaskedImage(),
1516 fwhm=self.config.brighterFatterFwhmForInterpolation,
1517 growSaturatedFootprints=self.config.growSaturationFootprintSize,
1518 maskNameList=list(self.config.brighterFatterMaskListToInterpolate),
1519 useLegacyInterp=self.config.useLegacyInterp,
1520 )
1521 bfExp = interpExp.clone()
1522 bfResults = isrFunctions.brighterFatterCorrection(bfExp, bfKernel,
1523 self.config.brighterFatterMaxIter,
1524 self.config.brighterFatterThreshold,
1525 brighterFatterApplyGain,
1526 bfGains)
1527 bfCorrIters = bfResults[1]
1528 if bfCorrIters == self.config.brighterFatterMaxIter:
1529 self.log.warning("Brighter-fatter correction did not converge, final difference %f.",
1530 bfResults[0])
1531 else:
1532 self.log.info("Finished brighter-fatter correction in %d iterations.",
1533 bfResults[1])
1534
1535 image = ccdExposure.getMaskedImage().getImage()
1536 bfCorr = bfExp.getMaskedImage().getImage()
1537 bfCorr -= interpExp.getMaskedImage().getImage()
1538 image += bfCorr
1539
1540 # Applying the brighter-fatter correction applies a
1541 # convolution to the science image. At the edges this
1542 # convolution may not have sufficient valid pixels to
1543 # produce a valid correction. Mark pixels within the size
1544 # of the brighter-fatter kernel as EDGE to warn of this
1545 # fact.
1546 self.log.info("Ensuring image edges are masked as EDGE to the brighter-fatter kernel size.")
1547 self.maskEdges(ccdExposure, numEdgePixels=numpy.max(bfKernel.shape) // 2,
1548 maskPlane="EDGE")
1549
1550 if self.config.brighterFatterMaskGrowSize > 0:
1551 self.log.info("Growing masks to account for brighter-fatter kernel convolution.")
1552 for maskPlane in self.config.brighterFatterMaskListToInterpolate:
1553 isrFunctions.growMasks(ccdExposure.getMask(),
1554 radius=self.config.brighterFatterMaskGrowSize,
1555 maskNameList=maskPlane,
1556 maskValue=maskPlane)
1557
1558 return ccdExposure, bfCorrIters
1559
1560 def darkCorrection(self, exposure, darkExposure, invert=False):
1561 """Apply dark correction in place.
1562
1563 Parameters
1564 ----------
1565 exposure : `lsst.afw.image.Exposure`
1566 Exposure to process.
1567 darkExposure : `lsst.afw.image.Exposure`
1568 Dark exposure of the same size as ``exposure``.
1569 invert : `Bool`, optional
1570 If True, re-add the dark to an already corrected image.
1571
1572 Raises
1573 ------
1574 RuntimeError
1575 Raised if either ``exposure`` or ``darkExposure`` do not
1576 have their dark time defined.
1577
1578 See Also
1579 --------
1580 lsst.ip.isr.isrFunctions.darkCorrection
1581 """
1582 expScale = exposure.visitInfo.darkTime
1583 if math.isnan(expScale):
1584 raise RuntimeError("Exposure darktime is NAN.")
1585 if darkExposure.visitInfo is not None \
1586 and not math.isnan(darkExposure.visitInfo.darkTime):
1587 darkScale = darkExposure.visitInfo.darkTime
1588 else:
1589 # DM-17444: darkExposure.visitInfo is None
1590 # so darkTime does not exist.
1591 self.log.warning("darkExposure.visitInfo does not exist. Using darkScale = 1.0.")
1592 darkScale = 1.0
1593
1594 isrFunctions.darkCorrection(
1595 maskedImage=exposure.maskedImage,
1596 darkMaskedImage=darkExposure.maskedImage,
1597 expScale=expScale,
1598 darkScale=darkScale,
1599 invert=invert,
1600 )
1601
1602 @staticmethod
1604 """Extract common calibration metadata values that will be written to
1605 output header.
1606
1607 Parameters
1608 ----------
1609 calib : `lsst.afw.image.Exposure` or `lsst.ip.isr.IsrCalib`
1610 Calibration to pull date information from.
1611
1612 Returns
1613 -------
1614 dateString : `str`
1615 Calibration creation date string to add to header.
1616 """
1617 if hasattr(calib, "getMetadata"):
1618 if 'CALIB_CREATION_DATE' in calib.metadata:
1619 return " ".join((calib.metadata.get("CALIB_CREATION_DATE", "Unknown"),
1620 calib.metadata.get("CALIB_CREATION_TIME", "Unknown")))
1621 else:
1622 return " ".join((calib.metadata.get("CALIB_CREATE_DATE", "Unknown"),
1623 calib.metadata.get("CALIB_CREATE_TIME", "Unknown")))
1624 else:
1625 return "Unknown Unknown"
1626
1627 def compareUnits(self, calibMetadata, calibName):
1628 """Compare units from calibration to ISR units.
1629
1630 This compares calibration units (adu or electron) to whether
1631 doApplyGain is set.
1632
1633 Parameters
1634 ----------
1635 calibMetadata : `lsst.daf.base.PropertyList`
1636 Calibration metadata from header.
1637 calibName : `str`
1638 Calibration name for log message.
1639 """
1640 calibUnits = calibMetadata.get("LSST ISR UNITS", "adu")
1641 isrUnits = "electron" if self.config.doApplyGains else "adu"
1642 if calibUnits != isrUnits:
1643 if self.config.doRaiseOnCalibMismatch:
1644 raise RuntimeError(
1645 "Unit mismatch: isr has %s units but %s has %s units",
1646 isrUnits,
1647 calibName,
1648 calibUnits,
1649 )
1650 else:
1651 self.log.warning(
1652 "Unit mismatch: isr has %s units but %s has %s units",
1653 isrUnits,
1654 calibName,
1655 calibUnits,
1656 )
1657
1658 def convertIntToFloat(self, exposure):
1659 """Convert exposure image from uint16 to float.
1660
1661 If the exposure does not need to be converted, the input is
1662 immediately returned. For exposures that are converted to use
1663 floating point pixels, the variance is set to unity and the
1664 mask to zero.
1665
1666 Parameters
1667 ----------
1668 exposure : `lsst.afw.image.Exposure`
1669 The raw exposure to be converted.
1670
1671 Returns
1672 -------
1673 newexposure : `lsst.afw.image.Exposure`
1674 The input ``exposure``, converted to floating point pixels.
1675
1676 Raises
1677 ------
1678 RuntimeError
1679 Raised if the exposure type cannot be converted to float.
1680
1681 """
1682 if isinstance(exposure, afwImage.ExposureF):
1683 # Nothing to be done
1684 self.log.debug("Exposure already of type float.")
1685 return exposure
1686 if not hasattr(exposure, "convertF"):
1687 raise RuntimeError("Unable to convert exposure (%s) to float." % type(exposure))
1688
1689 newexposure = exposure.convertF()
1690 newexposure.variance[:] = 1
1691 newexposure.mask[:] = 0x0
1692
1693 return newexposure
1694
1695 def ditherCounts(self, exposure, detectorConfig, fallbackSeed=12345):
1696 """Dither the counts in the exposure.
1697
1698 Parameters
1699 ----------
1700 exposure : `lsst.afw.image.Exposure`
1701 The raw exposure to be dithered.
1702 detectorConfig : `lsst.ip.isr.OverscanDetectorConfig`
1703 Configuration for overscan/etc for this detector.
1704 fallbackSeed : `int`, optional
1705 Random seed to fall back to if exposure.getInfo().getId() is
1706 not set.
1707 """
1708 if detectorConfig.integerDitherMode == "NONE":
1709 # Nothing to do here.
1710 return
1711
1712 # This ID is a unique combination of {exposure, detector} for a raw
1713 # image as we have here. We additionally need to take the lower
1714 # 32 bits to be used as a random seed.
1715 if exposure.info.id is not None:
1716 seed = exposure.info.id & 0xFFFFFFFF
1717 else:
1718 seed = fallbackSeed
1719 self.log.warning("No exposure ID found; using fallback random seed.")
1720
1721 self.log.info("Seeding dithering random number generator with %d.", seed)
1722 rng = numpy.random.RandomState(seed=seed)
1723
1724 if detectorConfig.integerDitherMode == "POSITIVE":
1725 low = 0.0
1726 high = 1.0
1727 elif detectorConfig.integerDitherMode == "NEGATIVE":
1728 low = -1.0
1729 high = 0.0
1730 elif detectorConfig.integerDitherMode == "SYMMETRIC":
1731 low = -0.5
1732 high = 0.5
1733 else:
1734 raise RuntimeError("Invalid config")
1735
1736 exposure.image.array[:, :] += rng.uniform(low=low, high=high, size=exposure.image.array.shape)
1737
1738 def checkBssVoltage(self, exposure):
1739 """Check the back-side bias voltage to see if the detector is on.
1740
1741 Parameters
1742 ----------
1743 exposure : `lsst.afw.image.ExposureF`
1744 Input exposure.
1745
1746 Raises
1747 ------
1748 `UnprocessableDataError` if voltage is off.
1749 """
1750 voltage = exposure.metadata.get(self.config.bssVoltageKeyword, None)
1751 if voltage is None or not numpy.isfinite(voltage):
1752 self.log.warning(
1753 "Back-side bias voltage %s not found in metadata.",
1754 self.config.bssVoltageKeyword,
1755 )
1756 return
1757
1758 hv = exposure.metadata.get(self.config.hvBiasKeyword, None)
1759 if hv is None:
1760 self.log.warning(
1761 "HV bias on %s not found in metadata.",
1762 self.config.hvBiasKeyword,
1763 )
1764 return
1765
1766 if voltage < self.config.bssVoltageMinimum or hv == "OFF":
1767 detector = exposure.getDetector()
1768 raise UnprocessableDataError(
1769 f"Back-side bias voltage is turned off for {detector.getName()}; skipping ISR.",
1770 )
1771
1772 @deprecated(
1773 reason=(
1774 "makeBinnedImages is no longer used. "
1775 "Please subtask lsst.ip.isr.BinExposureTask instead."
1776 ),
1777 version="v28", category=FutureWarning
1778 )
1779 def makeBinnedImages(self, exposure):
1780 """Make visualizeVisit style binned exposures.
1781
1782 Parameters
1783 ----------
1784 exposure : `lsst.afw.image.Exposure`
1785 Exposure to bin.
1786
1787 Returns
1788 -------
1789 bin1 : `lsst.afw.image.Exposure`
1790 Binned exposure using binFactor1.
1791 bin2 : `lsst.afw.image.Exposure`
1792 Binned exposure using binFactor2.
1793 """
1794 mi = exposure.getMaskedImage()
1795
1796 bin1 = afwMath.binImage(mi, self.config.binFactor1)
1797 bin2 = afwMath.binImage(mi, self.config.binFactor2)
1798
1799 bin1 = afwImage.makeExposure(bin1)
1800 bin2 = afwImage.makeExposure(bin2)
1801
1802 bin1.setInfo(exposure.getInfo())
1803 bin2.setInfo(exposure.getInfo())
1804
1805 return bin1, bin2
1806
1807 def run(self, ccdExposure, *, dnlLUT=None, bias=None, deferredChargeCalib=None, linearizer=None,
1808 ptc=None, crosstalk=None, defects=None, bfKernel=None, bfGains=None, dark=None,
1809 flat=None, camera=None, **kwargs
1810 ):
1811
1812 detector = ccdExposure.getDetector()
1813
1814 overscanDetectorConfig = self.config.overscanCamera.getOverscanDetectorConfig(detector)
1815
1816 if self.config.doBootstrap and ptc is not None:
1817 self.log.warning("Task configured with doBootstrap=True. Ignoring provided PTC.")
1818 ptc = None
1819
1820 if not self.config.doBootstrap:
1821 if ptc is None:
1822 raise RuntimeError("A PTC must be supplied if config.doBootstrap is False.")
1823
1824 # Validation step: check inputs match exposure configuration.
1825 exposureMetadata = ccdExposure.metadata
1826 doRaise = self.config.doRaiseOnCalibMismatch
1827 keywords = self.config.cameraKeywordsToCompare
1828 if not self.config.doBootstrap:
1829 compareCameraKeywords(doRaise, keywords, exposureMetadata, ptc, "PTC", log=self.log)
1830 else:
1831 if self.config.doCorrectGains:
1832 raise RuntimeError("doCorrectGains is True but no ptc provided.")
1833 if self.config.doDiffNonLinearCorrection:
1834 if dnlLUT is None:
1835 raise RuntimeError("doDiffNonLinearCorrection is True but no dnlLUT provided.")
1836 compareCameraKeywords(doRaise, keywords, exposureMetadata, dnlLUT, "dnlLUT", log=self.log)
1837 if self.config.doLinearize:
1838 if linearizer is None:
1839 raise RuntimeError("doLinearize is True but no linearizer provided.")
1840 compareCameraKeywords(doRaise, keywords, exposureMetadata, linearizer, "linearizer", log=self.log)
1841 if self.config.doBias:
1842 if bias is None:
1843 raise RuntimeError("doBias is True but no bias provided.")
1844 compareCameraKeywords(doRaise, keywords, exposureMetadata, bias, "bias", log=self.log)
1845 self.compareUnits(bias.metadata, "bias")
1846 if self.config.doCrosstalk:
1847 if crosstalk is None:
1848 raise RuntimeError("doCrosstalk is True but no crosstalk provided.")
1849 compareCameraKeywords(doRaise, keywords, exposureMetadata, crosstalk, "crosstalk", log=self.log)
1850 if self.config.doDeferredCharge:
1851 if deferredChargeCalib is None:
1852 raise RuntimeError("doDeferredCharge is True but no deferredChargeCalib provided.")
1854 doRaise,
1855 keywords,
1856 exposureMetadata,
1857 deferredChargeCalib,
1858 "CTI",
1859 log=self.log,
1860 )
1861 if self.config.doDefect:
1862 if defects is None:
1863 raise RuntimeError("doDefect is True but no defects provided.")
1864 compareCameraKeywords(doRaise, keywords, exposureMetadata, defects, "defects", log=self.log)
1865 if self.config.doDark:
1866 if dark is None:
1867 raise RuntimeError("doDark is True but no dark frame provided.")
1868 compareCameraKeywords(doRaise, keywords, exposureMetadata, dark, "dark", log=self.log)
1869 self.compareUnits(bias.metadata, "dark")
1870 if self.config.doBrighterFatter:
1871 if bfKernel is None:
1872 raise RuntimeError("doBrighterFatter is True not no bfKernel provided.")
1873 compareCameraKeywords(doRaise, keywords, exposureMetadata, bfKernel, "bf", log=self.log)
1874 if self.config.doFlat:
1875 if flat is None:
1876 raise RuntimeError("doFlat is True but no flat provided.")
1877 compareCameraKeywords(doRaise, keywords, exposureMetadata, flat, "flat", log=self.log)
1878
1879 if self.config.doSaturation:
1880 if self.config.defaultSaturationSource in ["PTCTURNOFF",]:
1881 if ptc is None:
1882 raise RuntimeError(
1883 "doSaturation is True and defaultSaturationSource is "
1884 f"{self.config.defaultSaturationSource}, but no ptc provided."
1885 )
1886 if self.config.doSuspect:
1887 if self.config.defaultSuspectSource in ["PTCTURNOFF",]:
1888 if ptc is None:
1889 raise RuntimeError(
1890 "doSuspect is True and defaultSuspectSource is "
1891 f"{self.config.defaultSuspectSource}, but no ptc provided."
1892 )
1893
1894 if self.config.doCheckUnprocessableData and self.config.bssVoltageMinimum > 0.0:
1895 self.checkBssVoltage(ccdExposure)
1896
1897 # FIXME: Make sure that if linearity is done then it is matched
1898 # with the right PTC.
1899
1900 # We keep track of units: start in adu.
1901 exposureMetadata["LSST ISR UNITS"] = "adu"
1902 exposureMetadata["LSST ISR CROSSTALK APPLIED"] = False
1903 exposureMetadata["LSST ISR OVERSCANLEVEL CHECKED"] = False
1904 exposureMetadata["LSST ISR NOISE CHECKED"] = False
1905 exposureMetadata["LSST ISR LINEARIZER APPLIED"] = False
1906 exposureMetadata["LSST ISR CTI APPLIED"] = False
1907 exposureMetadata["LSST ISR BIAS APPLIED"] = False
1908 exposureMetadata["LSST ISR DARK APPLIED"] = False
1909 exposureMetadata["LSST ISR BF APPLIED"] = False
1910 exposureMetadata["LSST ISR FLAT APPLIED"] = False
1911 exposureMetadata["LSST ISR DEFECTS APPLIED"] = False
1912
1913 if self.config.doBootstrap:
1914 self.log.info("Configured using doBootstrap=True; using gain of 1.0 (adu units)")
1915 ptc = PhotonTransferCurveDataset([amp.getName() for amp in detector], "NOMINAL_PTC", 1)
1916 for amp in detector:
1917 ptc.gain[amp.getName()] = 1.0
1918 ptc.noise[amp.getName()] = 0.0
1919
1920 exposureMetadata["LSST ISR BOOTSTRAP"] = self.config.doBootstrap
1921
1922 # Set which gains to use
1923 gains = ptc.gain
1924
1925 # And check if we have configured gains to override. This is
1926 # also a warning, since it should not be typical usage.
1927 for amp in detector:
1928 if not math.isnan(gain := overscanDetectorConfig.getOverscanAmpConfig(amp).gain):
1929 gains[amp.getName()] = gain
1930 self.log.warning(
1931 "Overriding gain for amp %s with configured value of %.3f.",
1932 amp.getName(),
1933 gain,
1934 )
1935
1936 # First we convert the exposure to floating point values
1937 # (if necessary).
1938 self.log.debug("Converting exposure to floating point values.")
1939 ccdExposure = self.convertIntToFloat(ccdExposure)
1940
1941 # Then we mark which amplifiers are completely bad from defects.
1942 badAmpDict = self.maskFullAmplifiers(ccdExposure, detector, defects, gains=gains)
1943
1944 self.checkAllBadAmps(badAmpDict, detector)
1945
1946 # Now we go through ISR steps.
1947
1948 # Differential non-linearity correction.
1949 # Units: adu
1950 if self.config.doDiffNonLinearCorrection:
1951 self.diffNonLinearCorrection(ccdExposure, dnlLUT)
1952
1953 # Dither the integer counts.
1954 # Input units: integerized adu
1955 # Output units: floating-point adu
1956 self.ditherCounts(ccdExposure, overscanDetectorConfig)
1957
1958 # Serial overscan correction.
1959 # Input units: adu
1960 # Output units: adu
1961 if overscanDetectorConfig.doAnySerialOverscan:
1962 serialOverscans = self.overscanCorrection(
1963 "SERIAL",
1964 overscanDetectorConfig,
1965 detector,
1966 badAmpDict,
1967 ccdExposure,
1968 )
1969
1970 if self.config.doBootstrap:
1971 # Get the empirical read noise
1972 for amp, serialOverscan in zip(detector, serialOverscans):
1973 if serialOverscan is None:
1974 ptc.noise[amp.getName()] = 0.0
1975 else:
1976 # All PhotonTransferCurveDataset objects should contain
1977 # noise attributes in units of electrons. The read
1978 # noise measured from overscans is always in adu, so we
1979 # scale it by the gain.
1980 # Note that in bootstrap mode, these gains will always
1981 # be 1.0, but we put this conversion here for clarity.
1982 ptc.noise[amp.getName()] = serialOverscan.residualSigma * gains[amp.getName()]
1983 else:
1984 serialOverscans = [None]*len(detector)
1985
1986 # After serial overscan correction, we can mask SATURATED and
1987 # SUSPECT pixels. This updates badAmpDict if any amplifier
1988 # is fully saturated after serial overscan correction.
1989
1990 # The saturation is currently assumed to be recorded in
1991 # overscan-corrected adu.
1992 badAmpDict = self.maskSaturatedPixels(
1993 badAmpDict,
1994 ccdExposure,
1995 detector,
1996 overscanDetectorConfig,
1997 ptc=ptc,
1998 )
1999
2000 self.checkAllBadAmps(badAmpDict, detector)
2001
2002 if self.config.doCorrectGains:
2003 # TODO: DM-36639
2004 # This requires the PTC (tbd) with the temperature dependence.
2005 self.log.info("Apply temperature dependence to the gains.")
2006 gains, readNoise = self.correctGains(ccdExposure, ptc, gains)
2007
2008 # Do gain normalization.
2009 # Input units: adu
2010 # Output units: electron
2011 if self.config.doApplyGains:
2012 self.log.info("Using gain values to convert from adu to electron units.")
2013 isrFunctions.applyGains(ccdExposure, normalizeGains=False, ptcGains=gains, isTrimmed=False)
2014 # The units are now electron.
2015 exposureMetadata["LSST ISR UNITS"] = "electron"
2016
2017 # Update the saturation units in the metadata if there.
2018 # These will always have the same units as the image.
2019 for amp in detector:
2020 ampName = amp.getName()
2021 if (key := f"LSST ISR SATURATION LEVEL {ampName}") in exposureMetadata:
2022 exposureMetadata[key] *= gains[ampName]
2023 if (key := f"LSST ISR SUSPECT LEVEL {ampName}") in exposureMetadata:
2024 exposureMetadata[key] *= gains[ampName]
2025
2026 # Record gain and read noise in header.
2027 metadata = ccdExposure.metadata
2028 metadata["LSST ISR READNOISE UNITS"] = "electron"
2029 for amp in detector:
2030 # This includes any gain correction (if applied).
2031 metadata[f"LSST ISR GAIN {amp.getName()}"] = gains[amp.getName()]
2032
2033 # At this stage, the read noise is always in electrons.
2034 noise = ptc.noise[amp.getName()]
2035 metadata[f"LSST ISR READNOISE {amp.getName()}"] = noise
2036
2037 # Do crosstalk correction in the full region.
2038 # Output units: electron (adu if doBootstrap=True)
2039 if self.config.doCrosstalk:
2040 self.log.info("Applying crosstalk corrections to full amplifier region.")
2041 if self.config.doBootstrap and numpy.any(crosstalk.fitGains != 0):
2042 crosstalkGains = None
2043 else:
2044 crosstalkGains = gains
2045 self.crosstalk.run(
2046 ccdExposure,
2047 crosstalk=crosstalk,
2048 gains=crosstalkGains,
2049 fullAmplifier=True,
2050 badAmpDict=badAmpDict,
2051 ignoreVariance=True,
2052 )
2053 ccdExposure.metadata["LSST ISR CROSSTALK APPLIED"] = True
2054
2055 # After crosstalk, we check for amplifier noise and state changes.
2056 if numpy.isfinite(self.config.serialOverscanMedianShiftSigmaThreshold):
2057 badAmpDict = self.checkAmpOverscanLevel(badAmpDict, ccdExposure, ptc)
2058 ccdExposure.metadata["LSST ISR OVERSCANLEVEL CHECKED"] = True
2059
2060 if numpy.isfinite(self.config.ampNoiseThreshold):
2061 badAmpDict = self.checkAmpNoise(badAmpDict, ccdExposure, ptc)
2062 ccdExposure.metadata["LSST ISR NOISE CHECKED"] = True
2063
2064 if numpy.isfinite(self.config.serialOverscanMedianShiftSigmaThreshold) or \
2065 numpy.isfinite(self.config.ampNoiseThreshold):
2066 self.checkAllBadAmps(badAmpDict, detector)
2067
2068 # Parallel overscan correction.
2069 # Output units: electron (adu if doBootstrap=True)
2070 parallelOverscans = None
2071 if overscanDetectorConfig.doAnyParallelOverscan:
2072 # At the moment we do not use the return values from this task.
2073 parallelOverscans = self.overscanCorrection(
2074 "PARALLEL",
2075 overscanDetectorConfig,
2076 detector,
2077 badAmpDict,
2078 ccdExposure,
2079 )
2080
2081 # Linearity correction
2082 # Output units: electron (adu if doBootstrap=True)
2083 if self.config.doLinearize:
2084 self.log.info("Applying linearizer.")
2085 # The linearizer is in units of adu.
2086 # If our units are electron, then pass in the gains
2087 # for conversion.
2088 if exposureMetadata["LSST ISR UNITS"] == "electron":
2089 linearityGains = gains
2090 else:
2091 linearityGains = None
2092 linearizer.applyLinearity(
2093 image=ccdExposure.image,
2094 detector=detector,
2095 log=self.log,
2096 gains=linearityGains,
2097 )
2098 ccdExposure.metadata["LSST ISR LINEARIZER APPLIED"] = True
2099
2100 # Serial CTI (deferred charge) correction
2101 # This will be performed in electron units
2102 # Output units: same as input units
2103 if self.config.doDeferredCharge:
2104 self.deferredChargeCorrection.run(
2105 ccdExposure,
2106 deferredChargeCalib,
2107 gains=gains,
2108 )
2109 ccdExposure.metadata["LSST ISR CTI APPLIED"] = True
2110
2111 # Save the untrimmed version for later statistics,
2112 # which still contains the overscan information
2113 untrimmedCcdExposure = None
2114 if self.config.isrStats.doCtiStatistics:
2115 untrimmedCcdExposure = ccdExposure.clone()
2116
2117 # Assemble/trim
2118 # Output units: electron (adu if doBootstrap=True)
2119 if self.config.doAssembleCcd:
2120 self.log.info("Assembling CCD from amplifiers.")
2121 ccdExposure = self.assembleCcd.assembleCcd(ccdExposure)
2122
2123 if self.config.expectWcs and not ccdExposure.getWcs():
2124 self.log.warning("No WCS found in input exposure.")
2125
2126 # E2V edge bleed
2127 if self.config.doE2VEdgeBleedMask and detector.getPhysicalType() == "E2V":
2128 isrFunctions.maskE2VEdgeBleed(
2129 exposure=ccdExposure,
2130 e2vEdgeBleedSatMinArea=self.config.e2vEdgeBleedSatMinArea,
2131 e2vEdgeBleedSatMaxArea=self.config.e2vEdgeBleedSatMaxArea,
2132 e2vEdgeBleedYMax=self.config.e2vEdgeBleedYMax,
2133 saturatedMaskName=self.config.saturatedMaskName,
2134 log=self.log,
2135 )
2136
2137 # ITL Dip Masking
2138 for maskPlane in self.config.itlDipMaskPlanes:
2139 if maskPlane not in ccdExposure.mask.getMaskPlaneDict():
2140 self.log.info("Adding %s mask plane to image.", maskPlane)
2141 ccdExposure.mask.addMaskPlane(maskPlane)
2142
2143 if self.config.doITLDipMask:
2144 isrFunctions.maskITLDip(
2145 exposure=ccdExposure,
2146 detectorConfig=overscanDetectorConfig,
2147 log=self.log,
2148 maskPlaneNames=self.config.itlDipMaskPlanes,
2149 )
2150
2151 if (self.config.doITLSatSagMask or self.config.doITLEdgeBleedMask) \
2152 and detector.getPhysicalType() == 'ITL':
2153 self.maskITLSatEdgesAndColumns(exposure=ccdExposure,
2154 badAmpDict=badAmpDict)
2155
2156 # Bias subtraction
2157 # Output units: electron (adu if doBootstrap=True)
2158 if self.config.doBias:
2159 self.log.info("Applying bias correction.")
2160 # Bias frame and ISR unit consistency is checked at the top of
2161 # the run method.
2162 isrFunctions.biasCorrection(ccdExposure.maskedImage, bias.maskedImage)
2163 ccdExposure.metadata["LSST ISR BIAS APPLIED"] = True
2164
2165 # Dark subtraction
2166 # Output units: electron (adu if doBootstrap=True)
2167 if self.config.doDark:
2168 self.log.info("Applying dark subtraction.")
2169 # Dark frame and ISR unit consistency is checked at the top of
2170 # the run method.
2171 self.darkCorrection(ccdExposure, dark)
2172 ccdExposure.metadata["LSST ISR DARK APPLIED"] = True
2173
2174 # Defect masking
2175 # Masking block (defects, NAN pixels and trails).
2176 # Saturated and suspect pixels have already been masked.
2177 # Output units: electron (adu if doBootstrap=True)
2178 if self.config.doDefect:
2179 self.log.info("Applying defect masking.")
2180 self.maskDefects(ccdExposure, defects)
2181 ccdExposure.metadata["LSST ISR DEFECTS APPLIED"] = True
2182
2183 self.log.info("Adding UNMASKEDNAN mask plane to image.")
2184 ccdExposure.mask.addMaskPlane("UNMASKEDNAN")
2185 if self.config.doNanMasking:
2186 self.log.info("Masking non-finite (NAN, inf) value pixels.")
2187 self.maskNan(ccdExposure)
2188
2189 if self.config.doWidenSaturationTrails:
2190 self.log.info("Widening saturation trails.")
2191 isrFunctions.widenSaturationTrails(ccdExposure.getMaskedImage().getMask())
2192
2193 # Brighter-Fatter
2194 # Output units: electron (adu if doBootstrap=True)
2195 if self.config.doBrighterFatter:
2196 self.log.info("Applying brighter-fatter correction.")
2197
2198 bfKernelOut, bfGains = self.getBrighterFatterKernel(detector, bfKernel)
2199
2200 # Needs to be done in electrons; applyBrighterFatterCorrection
2201 # will convert the image if necessary.
2202 if exposureMetadata["LSST ISR UNITS"] == "electron":
2203 brighterFatterApplyGain = False
2204 else:
2205 brighterFatterApplyGain = True
2206
2207 if brighterFatterApplyGain and (ptc is not None) and (bfGains != gains):
2208 # The supplied ptc should be the same as the ptc used to
2209 # generate the bfKernel, in which case they will have the
2210 # same stored amp-keyed dictionary of gains. If not, there
2211 # is a mismatch in the calibrations being used. This should
2212 # not be always be a fatal error, but ideally, everything
2213 # should to be consistent.
2214 self.log.warning("Need to apply gain for brighter-fatter, but the stored"
2215 "gains in the kernel are not the same as the gains stored"
2216 "in the PTC. Using the kernel gains.")
2217
2218 ccdExposure, bfCorrIters = self.applyBrighterFatterCorrection(
2219 ccdExposure,
2220 flat,
2221 dark,
2222 bfKernelOut,
2223 brighterFatterApplyGain,
2224 bfGains,
2225 )
2226
2227 ccdExposure.metadata["LSST ISR BF APPLIED"] = True
2228 metadata["LSST ISR BF ITERS"] = bfCorrIters
2229
2230 # Variance plane creation
2231 # Output units: electron (adu if doBootstrap=True)
2232 if self.config.doVariance:
2233 self.addVariancePlane(ccdExposure, detector)
2234
2235 # Flat-fielding
2236 # This may move elsewhere, but this is the most convenient
2237 # location for simple flat-fielding for attractive backgrounds.
2238 # Output units: electron (adu if doBootstrap=True)
2239 if self.config.doFlat:
2240 self.log.info("Applying flat correction.")
2241 isrFunctions.flatCorrection(
2242 maskedImage=ccdExposure.maskedImage,
2243 flatMaskedImage=flat.maskedImage,
2244 scalingType=self.config.flatScalingType,
2245 userScale=self.config.flatUserScale,
2246 )
2247
2248 # Copy over valid polygon from flat if it is
2249 # available, and set NO_DATA to 0.0 (which may
2250 # be inherited from the flat in the fully
2251 # vignetted region).
2252 if (validPolygon := flat.info.getValidPolygon()) is not None:
2253 ccdExposure.info.setValidPolygon(validPolygon)
2254
2255 noData = (ccdExposure.mask.array & ccdExposure.mask.getPlaneBitMask("NO_DATA")) > 0
2256 ccdExposure.image.array[noData] = 0.0
2257 ccdExposure.variance.array[noData] = 0.0
2258
2259 ccdExposure.metadata["LSST ISR FLAT APPLIED"] = True
2260 ccdExposure.metadata["LSST ISR FLAT SOURCE"] = flat.metadata.get("FLATSRC", "UNKNOWN")
2261
2262 # Pixel values for masked regions are set here
2263 preInterpExp = None
2264 if self.config.doSaveInterpPixels:
2265 preInterpExp = ccdExposure.clone()
2266
2267 if self.config.doSetBadRegions:
2268 self.log.info('Setting values in large contiguous bad regions.')
2269 self.setBadRegions(ccdExposure)
2270
2271 if self.config.doInterpolate:
2272 self.log.info("Interpolating masked pixels.")
2273 isrFunctions.interpolateFromMask(
2274 maskedImage=ccdExposure.getMaskedImage(),
2275 fwhm=self.config.brighterFatterFwhmForInterpolation,
2276 growSaturatedFootprints=self.config.growSaturationFootprintSize,
2277 maskNameList=list(self.config.maskListToInterpolate),
2278 useLegacyInterp=self.config.useLegacyInterp,
2279 )
2280
2281 # Calculate amp offset corrections within the CCD.
2282 if self.config.doAmpOffset:
2283 if self.config.ampOffset.doApplyAmpOffset:
2284 self.log.info("Measuring and applying amp offset corrections.")
2285 else:
2286 self.log.info("Measuring amp offset corrections only, without applying them.")
2287 self.ampOffset.run(ccdExposure)
2288
2289 # Calculate standard image quality statistics
2290 if self.config.doStandardStatistics:
2291 metadata = ccdExposure.metadata
2292 for amp in detector:
2293 ampExposure = ccdExposure.Factory(ccdExposure, amp.getBBox())
2294 ampName = amp.getName()
2295 metadata[f"LSST ISR MASK SAT {ampName}"] = isrFunctions.countMaskedPixels(
2296 ampExposure.getMaskedImage(),
2297 [self.config.saturatedMaskName]
2298 )
2299 metadata[f"LSST ISR MASK BAD {ampName}"] = isrFunctions.countMaskedPixels(
2300 ampExposure.getMaskedImage(),
2301 ["BAD"]
2302 )
2303 metadata[f"LSST ISR MASK SUSPECT {ampName}"] = isrFunctions.countMaskedPixels(
2304 ampExposure.getMaskedImage(),
2305 ["SUSPECT"],
2306 )
2307 qaStats = afwMath.makeStatistics(ampExposure.getImage(),
2308 afwMath.MEAN | afwMath.MEDIAN | afwMath.STDEVCLIP)
2309
2310 metadata[f"LSST ISR FINAL MEAN {ampName}"] = qaStats.getValue(afwMath.MEAN)
2311 metadata[f"LSST ISR FINAL MEDIAN {ampName}"] = qaStats.getValue(afwMath.MEDIAN)
2312 metadata[f"LSST ISR FINAL STDEV {ampName}"] = qaStats.getValue(afwMath.STDEVCLIP)
2313
2314 k1 = f"LSST ISR FINAL MEDIAN {ampName}"
2315 k2 = f"LSST ISR OVERSCAN SERIAL MEDIAN {ampName}"
2316 if overscanDetectorConfig.doAnySerialOverscan and k1 in metadata and k2 in metadata:
2317 metadata[f"LSST ISR LEVEL {ampName}"] = metadata[k1] - metadata[k2]
2318 else:
2319 metadata[f"LSST ISR LEVEL {ampName}"] = numpy.nan
2320
2321 # calculate additional statistics.
2322 outputStatistics = None
2323 if self.config.doCalculateStatistics:
2324 outputStatistics = self.isrStats.run(ccdExposure,
2325 untrimmedInputExposure=untrimmedCcdExposure,
2326 serialOverscanResults=serialOverscans,
2327 parallelOverscanResults=parallelOverscans,
2328 bias=bias, dark=dark, flat=flat,
2329 ptc=ptc, defects=defects).results
2330
2331 # do image binning.
2332 outputBin1Exposure = None
2333 outputBin2Exposure = None
2334 if self.config.doBinnedExposures:
2335 self.log.info("Creating binned exposures.")
2336 outputBin1Exposure = self.binning.run(
2337 ccdExposure,
2338 binFactor=self.config.binFactor1,
2339 ).binnedExposure
2340 outputBin2Exposure = self.binning.run(
2341 ccdExposure,
2342 binFactor=self.config.binFactor2,
2343 ).binnedExposure
2344
2345 return pipeBase.Struct(
2346 exposure=ccdExposure,
2347
2348 outputBin1Exposure=outputBin1Exposure,
2349 outputBin2Exposure=outputBin2Exposure,
2350
2351 preInterpExposure=preInterpExp,
2352 outputExposure=ccdExposure,
2353 outputStatistics=outputStatistics,
2354 )
Represent a 2-dimensional array of bitmask pixels.
Definition Mask.h:82
runQuantum(self, butlerQC, inputRefs, outputRefs)
ditherCounts(self, exposure, detectorConfig, fallbackSeed=12345)
flatContext(self, exp, flat, dark=None)
correctGains(self, exposure, ptc, gains)
run(self, ccdExposure, *, dnlLUT=None, bias=None, deferredChargeCalib=None, linearizer=None, ptc=None, crosstalk=None, defects=None, bfKernel=None, bfGains=None, dark=None, flat=None, camera=None, **kwargs)
checkAmpOverscanLevel(self, badAmpDict, exposure, ptc)
getBrighterFatterKernel(self, detector, bfKernel)
darkCorrection(self, exposure, darkExposure, invert=False)
compareUnits(self, calibMetadata, calibName)
overscanCorrection(self, mode, detectorConfig, detector, badAmpDict, ccdExposure)
applyBrighterFatterCorrection(self, ccdExposure, flat, dark, bfKernel, brighterFatterApplyGain, bfGains)
addVariancePlane(self, exposure, detector)
checkAmpNoise(self, badAmpDict, exposure, ptc)
diffNonLinearCorrection(self, ccdExposure, dnlLUT, **kwargs)
maskEdges(self, exposure, numEdgePixels=0, maskPlane="SUSPECT", level='DETECTOR')
maskDefects(self, exposure, defectBaseList)
checkAllBadAmps(self, badAmpDict, detector)
maskITLSatEdgesAndColumns(self, exposure, badAmpDict)
maskSaturatedPixels(self, badAmpDict, ccdExposure, detector, detectorConfig, ptc=None)
maskFullAmplifiers(self, ccdExposure, detector, defects, gains=None)
std::shared_ptr< Exposure< ImagePixelT, MaskPixelT, VariancePixelT > > makeExposure(MaskedImage< ImagePixelT, MaskPixelT, VariancePixelT > &mimage, std::shared_ptr< geom::SkyWcs const > wcs=std::shared_ptr< geom::SkyWcs const >())
A function to return an Exposure of the correct type (cf.
Definition Exposure.h:484
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
std::shared_ptr< ImageT > binImage(ImageT const &inImage, int const binX, int const binY, lsst::afw::math::Property const flags=lsst::afw::math::MEAN)
Definition binImage.cc:44
compareCameraKeywords(doRaiseOnCalibMismatch, cameraKeywordsToCompare, exposureMetadata, calib, calibName, log=None)
size_t maskNans(afw::image::MaskedImage< PixelT > const &mi, afw::image::MaskPixel maskVal, afw::image::MaskPixel allow=0)
Mask NANs in an image.
Definition Isr.cc:35