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