Coverage for python/lsst/cp/pipe/cpCtiSolve.py: 10%
323 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 01:22 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 01:22 -0800
1# This file is part of cp_pipe.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21#
22__all__ = ('CpCtiSolveConnections',
23 'CpCtiSolveConfig',
24 'CpCtiSolveTask',
25 )
27import numpy as np
29import lsst.pipe.base as pipeBase
30import lsst.pipe.base.connectionTypes as cT
31import lsst.pex.config as pexConfig
33from lsst.ip.isr.deferredCharge import (DeferredChargeCalib,
34 SimpleModel,
35 SimulatedModel,
36 SerialTrap)
37from lmfit import Minimizer, Parameters
38from astropy.stats import sigma_clip
41class CpCtiSolveConnections(pipeBase.PipelineTaskConnections,
42 dimensions=("instrument", "detector")):
43 inputMeasurements = cT.Input(
44 name="cpCtiMeas",
45 doc="Input overscan measurements to fit.",
46 storageClass='StructuredDataDict',
47 dimensions=("instrument", "exposure", "detector"),
48 multiple=True,
49 )
50 camera = cT.PrerequisiteInput(
51 name="camera",
52 doc="Camera geometry to use.",
53 storageClass="Camera",
54 dimensions=("instrument", ),
55 isCalibration=True,
56 )
57 linearizer = cT.PrerequisiteInput(
58 name="linearizer",
59 doc="Input linearizer for metadata inheritance.",
60 storageClass="Linearizer",
61 dimensions=("instrument", "detector"),
62 isCalibration=True,
63 )
64 outputCalib = cT.Output(
65 name="cti",
66 doc="Output CTI calibration.",
67 storageClass="IsrCalib",
68 dimensions=("instrument", "detector"),
69 isCalibration=True,
70 )
73class CpCtiSolveConfig(pipeBase.PipelineTaskConfig,
74 pipelineConnections=CpCtiSolveConnections):
75 """Configuration for the CTI combination.
76 """
77 maxImageMean = pexConfig.Field(
78 dtype=float,
79 default=150000.0,
80 doc="Upper limit on acceptable image flux mean (electron).",
81 )
82 localOffsetColumnRange = pexConfig.ListField(
83 dtype=int,
84 default=[3, 13],
85 doc="First and last overscan column to use for local offset effect.",
86 )
88 maxSignalForCti = pexConfig.Field(
89 dtype=float,
90 default=10000.0,
91 doc="Upper flux limit to use for CTI fit (electron).",
92 )
93 serialCtiRange = pexConfig.ListField(
94 dtype=float,
95 default=[-1.0e-5, 1.0e-5],
96 doc="Serial CTI range to search for serial turnoff.",
97 )
98 parallelCtiRange = pexConfig.ListField(
99 dtype=float,
100 default=[-1.0e-5, 1.0e-5],
101 doc="Parallel CTI range to search for parallel turnoff.",
102 )
103 turnoffFinderSigmaClip = pexConfig.Field(
104 dtype=int,
105 default=1,
106 doc="n for n*sigma to use for sigma clipping in turnoff finder.",
107 )
108 turnoffFinderSigmaClipMaxIters = pexConfig.Field(
109 dtype=int,
110 default=5,
111 doc="Maximum iterations for sigma clipping in turnoff finder.",
112 )
113 globalCtiColumnRange = pexConfig.ListField(
114 dtype=int,
115 default=[1, 15],
116 doc="First and last serial overscan column to use for "
117 "serial extended pixel edge response (EPER) estimate.",
118 )
119 globalCtiRowRange = pexConfig.ListField(
120 dtype=int,
121 default=[1, 2],
122 doc="First and last parallel overscan row to use for "
123 "parallel extended pixel edge response (EPER) estimate.",
124 )
126 trapColumnRange = pexConfig.ListField(
127 dtype=int,
128 default=[1, 2],
129 doc="First and last overscan column to use for serial trap fit.",
130 )
132 fitError = pexConfig.Field(
133 # This gives the error on the mean in a given column, and so
134 # is expected to be $RN / sqrt(N_rows)$.
135 dtype=float,
136 default=7.0/np.sqrt(2000),
137 doc="Error to use during parameter fitting.",
138 )
141class CpCtiSolveTask(pipeBase.PipelineTask):
142 """Combine CTI measurements to a final calibration.
144 This task uses the extended pixel edge response (EPER) method as
145 described by Snyder et al. 2021, Journal of Astronimcal
146 Telescopes, Instruments, and Systems, 7,
147 048002. doi:10.1117/1.JATIS.7.4.048002
148 """
150 ConfigClass = CpCtiSolveConfig
151 _DefaultName = 'cpCtiSolve'
153 def __init__(self, **kwargs):
154 super().__init__(**kwargs)
155 self.allowDebug = True
157 def runQuantum(self, butlerQC, inputRefs, outputRefs):
158 inputs = butlerQC.get(inputRefs)
160 dimensions = [dict(exp.dataId.required) for exp in inputRefs.inputMeasurements]
161 inputs['inputDims'] = dimensions
163 outputs = self.run(**inputs)
164 butlerQC.put(outputs, outputRefs)
166 def run(self, inputMeasurements, camera, inputDims, linearizer):
167 """Solve for charge transfer inefficiency from overscan measurements.
169 Parameters
170 ----------
171 inputMeasurements : `list` [`dict`]
172 List of overscan measurements from each input exposure.
173 Each dictionary is nested within a top level "CTI" key,
174 with measurements organized by amplifier name, containing
175 keys:
177 ``"FIRST_COLUMN_MEAN"``
178 Mean value of first image column (`float`).
179 ``"LAST_COLUMN_MEAN"``
180 Mean value of last image column (`float`).
181 ``"IMAGE_MEAN"``
182 Mean value of the entire image region (`float`).
183 ``"SERIAL_OVERSCAN_COLUMNS"``
184 List of overscan column indicies (`list` [`int`]).
185 ``"SERIAL_OVERSCAN_VALUES"``
186 List of overscan column means (`list` [`float`]).
187 ``"PARALLEL_OVERSCAN_ROWS"``
188 List of overscan row indicies (`list` [`int`]).
189 ``"PARALLEL_OVERSCAN_VALUES"``
190 List of overscan row means (`list` [`float`).
191 ``"INPUT_GAIN"``
192 The gains used to convert the image to electrons
193 before calculating CTI statistics. (`float`).
194 camera : `lsst.afw.cameraGeom.Camera`
195 Camera geometry to use to find detectors.
196 inputDims : `list` [`dict`]
197 List of input dimensions from each input exposure.
198 linearizer : `lsst.ip.isr.Linearizer`
199 Input linearizer. Used for metadata inheritance.
200 Returns
201 -------
202 results : `lsst.pipe.base.Struct`
203 Result struct containing:
205 ``outputCalib``
206 Final CTI calibration data
207 (`lsst.ip.isr.DeferredChargeCalib`).
209 Raises
210 ------
211 RuntimeError
212 Raised if data from multiple detectors are passed in.
213 """
214 detectorSet = set([d['detector'] for d in inputDims])
215 if len(detectorSet) != 1:
216 raise RuntimeError("Inputs for too many detectors passed.")
217 detectorId = detectorSet.pop()
218 detector = camera[detectorId]
220 # Initialize with detector.
221 calib = DeferredChargeCalib(camera=camera, detector=detector)
223 # Get the input gains used to measure CTI statistics
224 # All the input measurements (from individual exposures)
225 # will have the same gains, since they are processed
226 # together.
227 firstExposureEntry = inputMeasurements[0]
228 exposureDict = firstExposureEntry['CTI']
229 for amp in detector.getAmplifiers():
230 ampName = amp.getName()
231 calib.inputGain[ampName] = exposureDict[ampName]['INPUT_GAIN']
233 eperCalib = self.solveEper(inputMeasurements, calib, detector)
235 localCalib = self.solveLocalOffsets(inputMeasurements, eperCalib, detector)
237 globalCalib = self.solveGlobalCti(inputMeasurements, localCalib, detector)
239 finalCalib = self.findTraps(inputMeasurements, globalCalib, detector)
241 finalCalib.updateMetadataFromExposures([linearizer])
242 finalCalib.updateMetadata(setDate=True, camera=camera, detector=detector)
244 return pipeBase.Struct(
245 outputCalib=finalCalib,
246 )
248 def solveLocalOffsets(self, inputMeasurements, calib, detector):
249 """Solve for local (pixel-to-pixel) electronic offsets.
251 This method fits for \tau_L, the local electronic offset decay
252 time constant, and A_L, the local electronic offset constant
253 of proportionality.
255 Parameters
256 ----------
257 inputMeasurements : `list` [`dict`]
258 List of overscan measurements from each input exposure.
259 Each dictionary is nested within a top level "CTI" key,
260 with measurements organized by amplifier name, containing
261 keys:
263 ``"FIRST_COLUMN_MEAN"``
264 Mean value of first image column (`float`).
265 ``"LAST_COLUMN_MEAN"``
266 Mean value of last image column (`float`).
267 ``"IMAGE_MEAN"``
268 Mean value of the entire image region (`float`).
269 ``"SERIAL_OVERSCAN_COLUMNS"``
270 List of overscan column indicies (`list` [`int`]).
271 ``"SERIAL_OVERSCAN_VALUES"``
272 List of overscan column means (`list` [`float`]).
273 ``"PARALLEL_OVERSCAN_ROWS"``
274 List of overscan row indicies (`list` [`int`]).
275 ``"PARALLEL_OVERSCAN_VALUES"``
276 List of overscan row means (`list` [`float`).
277 calib : `lsst.ip.isr.DeferredChargeCalib`
278 Calibration to populate with values.
279 detector : `lsst.afw.cameraGeom.Detector`
280 Detector object containing the geometry information for
281 the amplifiers.
283 Returns
284 -------
285 calib : `lsst.ip.isr.DeferredChargeCalib`
286 Populated calibration.
288 Raises
289 ------
290 RuntimeError
291 Raised if no data remains after flux filtering.
293 Notes
294 -----
295 The original CTISIM code (https://github.com/Snyder005/ctisim)
296 uses a data model in which the "overscan" consists of the
297 standard serial overscan bbox with the values for the last
298 imaging data column prepended to that list. This version of
299 the code keeps the overscan and imaging sections separate, and
300 so a -1 offset is needed to ensure that the same columns are
301 used for fitting between this code and CTISIM. This offset
302 removes that last imaging data column from the count.
303 """
304 # Range to fit. These are in "camera" coordinates, and so
305 # need to have the count for last image column removed.
306 start, stop = self.config.localOffsetColumnRange
307 start -= 1
308 stop -= 1
310 # Loop over amps/inputs, fitting those columns from
311 # "non-saturated" inputs.
312 for amp in detector.getAmplifiers():
313 ampName = amp.getName()
315 # Number of serial shifts.
316 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
318 # The signal is the mean intensity of each input, and the
319 # data are the overscan columns to fit. For detectors
320 # with non-zero CTI, the charge from the imaging region
321 # leaks into the overscan region.
322 signal = []
323 data = []
324 nSkipped = 0
325 for exposureEntry in inputMeasurements:
326 exposureDict = exposureEntry['CTI']
327 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean:
328 imMean = exposureDict[ampName]['IMAGE_MEAN']
329 sOverscan = exposureDict[ampName]['SERIAL_OVERSCAN_VALUES'][start: stop + 1]
330 if not np.all(np.isfinite(imMean)) or not np.all(np.isfinite(sOverscan)):
331 self.log.warning("NaN detected in an entry for amplifier %s.", ampName)
332 nSkipped += 1
333 else:
334 signal.append(imMean)
335 data.append(sOverscan)
336 else:
337 nSkipped += 1
338 self.log.info("Skipped %d exposures brighter than %f.",
339 nSkipped, self.config.maxImageMean)
340 if len(signal) == 0 or len(data) == 0:
341 # All exposures excluded, set the calibration so that
342 # there is no correction
343 self.log.warning("All exposures brighter than config.maxImageMean are excluded. "
344 "Setting local offset drift scale to zero for amp %s.",
345 ampName)
346 # Arbitrary, will be overwritten by solveGlobalCti
347 calib.globalCti[ampName] = 10**(-6)
348 # Set to zero so that there is no correction
349 calib.driftScale[ampName] = 0.0
350 # Arbitrary, unused if driftScale=0
351 calib.decayTime[ampName] = 2.4
352 continue
354 signal = np.array(signal)
355 data = np.array(data)
357 ind = signal.argsort()
358 signal = signal[ind]
359 data = data[ind]
361 params = Parameters()
362 params.add('ctiexp', value=-6, min=-7, max=-5, vary=False)
363 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False)
364 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False)
365 params.add('emissiontime', value=0.4, min=0.1, max=1.0, vary=False)
366 params.add('driftscale', value=0.00022, min=0., max=0.001, vary=True)
367 params.add('decaytime', value=2.4, min=0.1, max=4.0, vary=True)
369 model = SimpleModel()
370 minner = Minimizer(model.difference, params,
371 fcn_args=(signal, data, self.config.fitError, nCols),
372 fcn_kws={'start': start, 'stop': stop})
373 result = minner.minimize()
375 # Save results for the drift scale and decay time.
376 if not result.success:
377 self.log.warning("Electronics fitting failure for amplifier %s.", ampName)
379 calib.globalCti[ampName] = 10**result.params['ctiexp']
380 calib.driftScale[ampName] = result.params['driftscale'].value if result.success else 0.0
381 calib.decayTime[ampName] = result.params['decaytime'].value if result.success else 2.4
382 self.log.info("CTI Local Fit %s: cti: %g decayTime: %g driftScale %g",
383 ampName, calib.globalCti[ampName], calib.decayTime[ampName],
384 calib.driftScale[ampName])
385 return calib
387 def solveGlobalCti(self, inputMeasurements, calib, detector):
388 """Solve for global CTI constant.
390 This method solves for the mean global CTI, b.
392 Parameters
393 ----------
394 inputMeasurements : `list` [`dict`]
395 List of overscan measurements from each input exposure.
396 Each dictionary is nested within a top level "CTI" key,
397 with measurements organized by amplifier name, containing
398 keys:
400 ``"FIRST_COLUMN_MEAN"``
401 Mean value of first image column (`float`).
402 ``"LAST_COLUMN_MEAN"``
403 Mean value of last image column (`float`).
404 ``"IMAGE_MEAN"``
405 Mean value of the entire image region (`float`).
406 ``"SERIAL_OVERSCAN_COLUMNS"``
407 List of overscan column indicies (`list` [`int`]).
408 ``"SERIAL_OVERSCAN_VALUES"``
409 List of overscan column means (`list` [`float`]).
410 ``"PARALLEL_OVERSCAN_ROWS"``
411 List of overscan row indicies (`list` [`int`]).
412 ``"PARALLEL_OVERSCAN_VALUES"``
413 List of overscan row means (`list` [`float`).
414 calib : `lsst.ip.isr.DeferredChargeCalib`
415 Calibration to populate with values.
416 detector : `lsst.afw.cameraGeom.Detector`
417 Detector object containing the geometry information for
418 the amplifiers.
420 Returns
421 -------
422 calib : `lsst.ip.isr.DeferredChargeCalib`
423 Populated calibration.
425 Raises
426 ------
427 RuntimeError
428 Raised if no data remains after flux filtering.
430 Notes
431 -----
432 The original CTISIM code uses a data model in which the
433 "overscan" consists of the standard serial overscan bbox with
434 the values for the last imaging data column prepended to that
435 list. This version of the code keeps the overscan and imaging
436 sections separate, and so a -1 offset is needed to ensure that
437 the same columns are used for fitting between this code and
438 CTISIM. This offset removes that last imaging data column
439 from the count.
440 """
441 # Range to fit. These are in "camera" coordinates, and so
442 # need to have the count for last image column removed.
443 start, stop = self.config.globalCtiColumnRange
444 start -= 1
445 stop -= 1
447 # Loop over amps/inputs, fitting those columns from
448 # "non-saturated" inputs.
449 for amp in detector.getAmplifiers():
450 ampName = amp.getName()
452 # Number of serial shifts.
453 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
455 # The signal is the mean intensity of each input, and the
456 # data are the overscan columns to fit. For detectors
457 # with non-zero CTI, the charge from the imaging region
458 # leaks into the overscan region.
459 signal = []
460 data = []
461 nSkipped = 0
462 for exposureEntry in inputMeasurements:
463 exposureDict = exposureEntry['CTI']
464 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxSignalForCti:
465 imMean = exposureDict[ampName]['IMAGE_MEAN']
466 sOverscan = exposureDict[ampName]['SERIAL_OVERSCAN_VALUES'][start: stop + 1]
467 if not np.all(np.isfinite(imMean)) or not np.all(np.isfinite(sOverscan)):
468 self.log.warning("NaN detected in an entry for amplifier %s.", ampName)
469 nSkipped += 1
470 else:
471 signal.append(imMean)
472 data.append(sOverscan)
473 else:
474 nSkipped += 1
475 self.log.info(f"Skipped {nSkipped} exposures brighter than {self.config.maxSignalForCti}.")
476 if len(signal) == 0 or len(data) == 0:
477 # There are no exposures left, set globalCTI to 0
478 self.log.warning("All exposures brighter than config.maxSignalForCti=%f "
479 "are excluded for %s. Setting global CTI to zero.",
480 self.config.maxSignalForCti, ampName)
481 calib.globalCti[ampName] = 0.0
482 continue
484 signal = np.array(signal)
485 data = np.array(data)
487 ind = signal.argsort()
488 signal = signal[ind]
489 data = data[ind]
491 # CTI test. This looks at the charge that has leaked into
492 # the first few columns of the overscan.
493 overscan1 = data[:, 0]
494 overscan2 = data[:, 1]
495 test = (np.array(overscan1) + np.array(overscan2))/(nCols*np.array(signal))
496 testResult = np.median(test) > 5.E-6
497 self.log.info("Estimate of CTI test is %f for amp %s, %s.", np.median(test), ampName,
498 "full fitting will be performed" if testResult else
499 "only global CTI fitting will be performed")
501 self.debugView(ampName, signal, test)
503 params = Parameters()
504 params.add('ctiexp', value=-6, min=-8, max=-5, vary=True)
505 params.add('trapsize', value=5.0 if testResult else 0.0, min=0.0, max=30.,
506 vary=True if testResult else False)
507 params.add('scaling', value=0.08, min=0.0, max=1.0,
508 vary=True if testResult else False)
509 params.add('emissiontime', value=0.35, min=0.1, max=1.0,
510 vary=True if testResult else False)
511 params.add('driftscale', value=calib.driftScale[ampName], min=0., max=0.001, vary=False)
512 params.add('decaytime', value=calib.decayTime[ampName], min=0.1, max=4.0, vary=False)
514 model = SimulatedModel()
515 minner = Minimizer(model.difference, params,
516 fcn_args=(signal, data, self.config.fitError, nCols, amp),
517 fcn_kws={'start': start, 'stop': stop, 'trap_type': 'linear'})
518 result = minner.minimize()
520 # Warn if the global CTI has hit the upper bound of the fit range.
521 if np.isclose(10**result.params["ctiexp"].value, 10**(-5), rtol=.01):
522 self.log.warning(f"Global CTI for detector {detector.getId()} ({amp.getName()}) is "
523 "has hit the fitter's upper bound (10^-5).")
525 # Only the global CTI term is retained from this fit.
526 # If the CTI is within 1% of the lower bound, just set
527 # the CTI to zero and don't perform a global CTI
528 # correction.
529 if np.isclose(10**result.params["ctiexp"].value, 1e-8, rtol=0.01):
530 self.log.info(f"Global CTI for detector {detector.getId()} ({amp.getName()}) has "
531 "hit the fitter's lower bound; setting globalCti to 0.")
532 calib.globalCti[ampName] = 0.0
533 else:
534 calib.globalCti[ampName] = 10**result.params['ctiexp'].value
536 self.log.info("CTI Global Cti %s: cti: %g decayTime: %g driftScale %g",
537 ampName, calib.globalCti[ampName], calib.decayTime[ampName],
538 calib.driftScale[ampName])
540 return calib
542 def solveEper(self, inputMeasurements, calib, detector):
543 """Solve for serial and parallel extended pixel edge response (EPER),
544 which is an esimator of CTI defined in Snyder et al. 2021.
546 Parameters
547 ----------
548 inputMeasurements : `list` [`dict`]
549 List of overscan measurements from each input exposure.
550 Each dictionary is nested within a top level "CTI" key,
551 with measurements organized by amplifier name, containing
552 keys:
554 ``"FIRST_COLUMN_MEAN"``
555 Mean value of first image column (`float`).
556 ``"LAST_COLUMN_MEAN"``
557 Mean value of last image column (`float`).
558 ``"IMAGE_MEAN"``
559 Mean value of the entire image region (`float`).
560 ``"SERIAL_OVERSCAN_COLUMNS"``
561 List of overscan column indicies (`list` [`int`]).
562 ``"SERIAL_OVERSCAN_VALUES"``
563 List of overscan column means (`list` [`float`]).
564 ``"PARALLEL_OVERSCAN_ROWS"``
565 List of overscan row indicies (`list` [`int`]).
566 ``"PARALLEL_OVERSCAN_VALUES"``
567 List of overscan row means (`list` [`float`).
568 calib : `lsst.ip.isr.DeferredChargeCalib`
569 Calibration to populate with values.
570 detector : `lsst.afw.cameraGeom.Detector`
571 Detector object containing the geometry information for
572 the amplifiers.
574 Returns
575 -------
576 calib : `lsst.ip.isr.DeferredChargeCalib`
577 Populated calibration.
579 Notes
580 -----
581 The original CTISIM code uses a data model in which the
582 "overscan" consists of the standard serial overscan bbox with
583 the values for the last imaging data column prepended to that
584 list. This version of the code keeps the overscan and imaging
585 sections separate, and so a -1 offset is needed to ensure that
586 the same columns are used for fitting between this code and
587 CTISIM. This offset removes that last imaging data column
588 from the count.
589 """
590 for amp in detector.getAmplifiers():
591 ampName = amp.getName()
592 # Do serial EPER calculation
593 signals, serialEperEstimate = self.calcEper(
594 "SERIAL",
595 inputMeasurements,
596 amp,
597 )
599 # Do parallel EPER calculation
600 signals, parallelEperEstimate = self.calcEper(
601 "PARALLEL",
602 inputMeasurements,
603 amp,
604 )
606 # Calculate the serial and parallel turnoffs
607 serialCtiTurnoff, serialCtiTurnoffSamplingErr = self.calcCtiTurnoff(
608 signals,
609 serialEperEstimate,
610 self.config.serialCtiRange,
611 amp,
612 )
613 parallelCtiTurnoff, parallelCtiTurnoffSamplingErr = self.calcCtiTurnoff(
614 signals,
615 parallelEperEstimate,
616 self.config.parallelCtiRange,
617 amp,
618 )
620 # Output the results
621 self.log.info("Amp %s: Setting serial CTI turnoff to %f +/- %f",
622 amp.getName(), serialCtiTurnoff, serialCtiTurnoffSamplingErr)
623 self.log.info("Amp %s: Setting parallel CTI turnoff to %f +/- %f",
624 amp.getName(), parallelCtiTurnoff, parallelCtiTurnoffSamplingErr)
626 # Save everything to the DeferredChargeCalib
627 calib.signals[ampName] = signals
628 calib.serialEper[ampName] = serialEperEstimate
629 calib.parallelEper[ampName] = parallelEperEstimate
630 calib.serialCtiTurnoff[ampName] = serialCtiTurnoff
631 calib.parallelCtiTurnoff[ampName] = parallelCtiTurnoff
632 calib.serialCtiTurnoffSamplingErr[ampName] = serialCtiTurnoffSamplingErr
633 calib.parallelCtiTurnoffSamplingErr[ampName] = parallelCtiTurnoffSamplingErr
635 return calib
637 def debugView(self, ampName, signal, test):
638 """Debug method for global CTI test value.
640 Parameters
641 ----------
642 ampName : `str`
643 Name of the amp for plot title.
644 signal : `list` [`float`]
645 Image means for the input exposures.
646 test : `list` [`float`]
647 CTI test value to plot.
648 """
649 import lsstDebug
650 if not lsstDebug.Info(__name__).display:
651 return
652 if not self.allowDebug:
653 return
655 import matplotlib.pyplot as plot
656 figure = plot.figure(1)
657 figure.clear()
658 plot.xscale('log', base=10.0)
659 plot.yscale('log', base=10.0)
660 plot.xlabel('Flat Field Signal [e-?]')
661 plot.ylabel('Serial CTI')
662 plot.title(ampName)
663 plot.plot(signal, test)
665 figure.show()
666 prompt = "Press Enter or c to continue [chp]..."
667 while True:
668 ans = input(prompt).lower()
669 if ans in ("", " ", "c",):
670 break
671 elif ans in ("p", ):
672 import pdb
673 pdb.set_trace()
674 elif ans in ('x', ):
675 self.allowDebug = False
676 break
677 elif ans in ("h", ):
678 print("[h]elp [c]ontinue [p]db e[x]itDebug")
679 plot.close()
681 def findTraps(self, inputMeasurements, calib, detector):
682 """Solve for serial trap parameters.
684 Parameters
685 ----------
686 inputMeasurements : `list` [`dict`]
687 List of overscan measurements from each input exposure.
688 Each dictionary is nested within a top level "CTI" key,
689 with measurements organized by amplifier name, containing
690 keys:
692 ``"FIRST_COLUMN_MEAN"``
693 Mean value of first image column (`float`).
694 ``"LAST_COLUMN_MEAN"``
695 Mean value of last image column (`float`).
696 ``"IMAGE_MEAN"``
697 Mean value of the entire image region (`float`).
698 ``"SERIAL_OVERSCAN_COLUMNS"``
699 List of overscan column indicies (`list` [`int`]).
700 ``"SERIAL_OVERSCAN_VALUES"``
701 List of overscan column means (`list` [`float`]).
702 calib : `lsst.ip.isr.DeferredChargeCalib`
703 Calibration to populate with values.
704 detector : `lsst.afw.cameraGeom.Detector`
705 Detector object containing the geometry information for
706 the amplifiers.
708 Returns
709 -------
710 calib : `lsst.ip.isr.DeferredChargeCalib`
711 Populated calibration.
713 Raises
714 ------
715 RuntimeError
716 Raised if no data remains after flux filtering.
718 Notes
719 -----
720 The original CTISIM code uses a data model in which the
721 "overscan" consists of the standard serial overscan bbox with
722 the values for the last imaging data column prepended to that
723 list. This version of the code keeps the overscan and imaging
724 sections separate, and so a -1 offset is needed to ensure that
725 the same columns are used for fitting between this code and
726 CTISIM. This offset removes that last imaging data column
727 from the count.
728 """
729 # Range to fit. These are in "camera" coordinates, and so
730 # need to have the count for last image column removed.
731 start, stop = self.config.trapColumnRange
732 start -= 1
733 stop -= 1
735 # Loop over amps/inputs, fitting those columns from
736 # "non-saturated" inputs.
737 for amp in detector.getAmplifiers():
738 ampName = amp.getName()
740 # Number of serial shifts.
741 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
743 # The signal is the mean intensity of each input, and the
744 # data are the overscan columns to fit. The new_signal is
745 # the mean in the last image column. Any serial trap will
746 # take charge from this column, and deposit it into the
747 # overscan columns.
748 signal = []
749 data = []
750 new_signal = []
751 nSkipped = 0
752 for exposureEntry in inputMeasurements:
753 exposureDict = exposureEntry['CTI']
754 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean:
755 imMean = exposureDict[ampName]['IMAGE_MEAN']
756 sOverscan = exposureDict[ampName]['SERIAL_OVERSCAN_VALUES'][start: stop + 1]
757 lastColumnMean = exposureDict[ampName]['LAST_COLUMN_MEAN']
758 if not np.all(np.isfinite(imMean)) or not np.all(np.isfinite(sOverscan)) \
759 or not np.all(np.isfinite(lastColumnMean)):
760 self.log.warning("NaN detected in an entry for amplifier %s.", ampName)
761 nSkipped += 1
762 else:
763 signal.append(imMean)
764 data.append(sOverscan)
765 new_signal.append(lastColumnMean)
766 else:
767 nSkipped += 1
768 self.log.info("Skipped %d exposures brighter than %f.",
769 nSkipped, self.config.maxImageMean)
770 if len(signal) == 0 or len(data) == 0:
771 # There are no exposures left, so set trap so that
772 # there is no correction
773 self.log.warning("All exposures brighter than config.maxImageMean are excluded. "
774 "Setting zero-sized serial trap for amp %s.", ampName)
775 # Arbitrary trap with no size
776 trap = SerialTrap(0.0, 0.4, 1, 'linear', [1.0])
777 calib.serialTraps[ampName] = trap
778 continue
780 signal = np.array(signal)
781 data = np.array(data)
782 new_signal = np.array(new_signal)
784 ind = signal.argsort()
785 signal = signal[ind]
786 data = data[ind]
787 new_signal = new_signal[ind]
789 # In the absense of any trap, the model results using the
790 # parameters already determined will match the observed
791 # overscan results.
792 params = Parameters()
794 if calib.globalCti[ampName] == 0:
795 globalCtiPower = -18 # Something small
796 else:
797 globalCtiPower = np.log10(calib.globalCti[ampName])
799 params.add('ctiexp', value=globalCtiPower,
800 min=-18, max=-5, vary=False)
801 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False)
802 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False)
803 params.add('emissiontime', value=0.35, min=0.1, max=1.0, vary=False)
804 params.add('driftscale', value=calib.driftScale[ampName],
805 min=0.0, max=0.001, vary=False)
806 params.add('decaytime', value=calib.decayTime[ampName],
807 min=0.1, max=4.0, vary=False)
809 model = SimpleModel.model_results(params, signal, nCols,
810 start=start, stop=stop)
812 # Evaluating trap: the difference between the model and
813 # observed data.
814 res = np.sum((data-model)[:, :3], axis=1)
816 # Create spline model for the trap, using the residual
817 # between data and model as a function of the last image
818 # column mean (new_signal) scaled by (1 - A_L).
819 # Note that this ``spline`` model is actually a piecewise
820 # linear interpolation and not a true spline.
821 new_signal = np.asarray((1 - calib.driftScale[ampName])*new_signal, dtype=np.float64)
822 x = new_signal
823 y = np.maximum(0, res)
825 # Pad left with ramp
826 y = np.pad(y, (10, 0), 'linear_ramp', end_values=(0, 0))
827 x = np.pad(x, (10, 0), 'linear_ramp', end_values=(0, 0))
829 trap = SerialTrap(20000.0, 0.4, 1, 'spline', np.concatenate((x, y)).tolist())
830 calib.serialTraps[ampName] = trap
832 return calib
834 def calcEper(self, mode, inputMeasurements, amp):
835 """Solve for serial or parallel global CTI using the extended
836 pixel edge response (EPER) method.
838 Parameters
839 ----------
840 mode : `str`
841 The orientation of the calculation to perform. Can be
842 either "SERIAL" or "PARALLEL".
843 inputMeasurements : `list` [`dict`]
844 List of overscan measurements from each input exposure.
845 Each dictionary is nested within a top level "CTI" key,
846 with measurements organized by amplifier name, containing
847 keys:
849 ``"FIRST_COLUMN_MEAN"``
850 Mean value of first image column (`float`).
851 ``"LAST_COLUMN_MEAN"``
852 Mean value of last image column (`float`).
853 ``"IMAGE_MEAN"``
854 Mean value of the entire image region (`float`).
855 ``"SERIAL_OVERSCAN_COLUMNS"``
856 List of serial overscan column
857 indicies (`list` [`int`]).
858 ``"SERIAL_OVERSCAN_VALUES"``
859 List of serial overscan column
860 means (`list` [`float`]).
861 ``"PARALLEL_OVERSCAN_ROWS"``
862 List of parallel overscan row
863 indicies (`list` [`int`]).
864 ``"PARALLEL_OVERSCAN_VALUES"``
865 List of parallel overscan row
866 means (`list` [`float`]).
867 calib : `lsst.ip.isr.DeferredChargeCalib`
868 Calibration to populate with values.
869 amp : `lsst.afw.cameraGeom.Amplifier`
870 Amplifier object containing the geometry information for
871 the amplifier.
873 Returns
874 -------
875 calib : `lsst.ip.isr.DeferredChargeCalib`
876 Populated calibration.
878 Raises
879 ------
880 RuntimeError : Raised if no data remains after flux filtering or if
881 the mode string is not one of "SERIAL" or "PARALLEL".
882 """
883 ampName = amp.getName()
885 # First, check if there are input measurements
886 if len(inputMeasurements) == 0:
887 raise RuntimeError("No input measurements to solve for EPER.")
889 # Range to fit. These are in "camera" coordinates, and so
890 # need to have the count for last image column removed.
891 if mode == "SERIAL":
892 start, stop = self.config.globalCtiColumnRange
893 start -= 1
894 stop -= 1
896 # Number of serial shifts = nCols
897 nShifts = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
898 elif mode == "PARALLEL":
899 start, stop = self.config.globalCtiRowRange
900 start -= 1
901 stop -= 1
903 # Number of parallel shifts = nRows
904 nShifts = amp.getRawDataBBox().getHeight()
905 else:
906 raise RuntimeError(f"{mode} is not a known orientation for the EPER calculation.")
908 # The signal is the mean intensity of each input, and the
909 # data are the overscan columns to fit. For detectors
910 # with non-zero CTI, the charge from the imaging region
911 # leaks into the overscan region.
912 signal = []
913 data = []
914 for exposureEntry in inputMeasurements:
915 exposureDict = exposureEntry['CTI']
916 signal.append(exposureDict[ampName]['IMAGE_MEAN'])
917 data.append(exposureDict[ampName][f'{mode}_OVERSCAN_VALUES'][start:stop+1])
919 signal = np.array(signal)
920 data = np.array(data)
922 ind = signal.argsort()
923 signal = signal[ind]
924 data = data[ind]
926 # This looks at the charge that has leaked into
927 # the first few columns or rows of the overscan.
928 overscan1 = data[:, 0]
929 overscan2 = data[:, 1]
930 ctiEstimate = (overscan1 + overscan2)/(nShifts*np.array(signal))
932 return signal, ctiEstimate
934 def calcCtiTurnoff(self, signalVec, dataVec, ctiRange, amp):
935 """Solve for turnoff value in a sequenced dataset.
937 Parameters
938 ----------
939 signalVec : `np.ndarray`
940 Signal values for the dataset. Must be sorted
941 in ascending order.
942 dataVec : `np.ndarray`
943 Data values for the dataset. Must be sorted
944 in ascending order.
945 ctiRange : `list` [`float`]
946 Range of CTI within which to search for the
947 turnoff point.
949 Returns
950 -------
951 turnoff : `float`
952 The turnoff point in the same units as the
953 input signals
954 turnoffSamplingError : `float`
955 The sampling error of the turnoff point, equals
956 turnoff when not enough data points.
958 Notes
959 ------
960 If the data is sparse and does not cover the turnoff region,
961 it will likely just return the highest signal in the dataset.
963 However, it will issue a warning if the turnoff point is at
964 the edge of the search range.
965 """
966 # First, trim the data
967 idxs = (dataVec >= ctiRange[0]) * (dataVec <= ctiRange[1])
968 dataVec = dataVec[idxs]
969 signalVec = signalVec[idxs]
971 # Check for remaining data points
972 if dataVec.size == 0:
973 self.log.warning("No data points after cti range cut to compute turnoff "
974 "for amplifier %s. Setting turnoff point to 0 electrons.",
975 amp.getName())
976 return 0.0, 0.0
978 if dataVec.size < 2:
979 self.log.warning("Insufficient data points after cti range cut to compute turnoff "
980 "for amplifier %s. Setting turnoff point to the maximum signal "
981 "value.", amp.getName())
982 return signalVec[-1], signalVec[-1]
984 # Detrend the data
985 # We will use np.gradient since this method of
986 # detrending turns out to be more practical
987 # than using np.diff, which tends to be noisier.
988 # Besides, this tends to filter out the low
989 # gradient features of the data, particularly
990 # in the parallel turnoff.
991 detrendedDataVec = np.gradient(dataVec)
993 # Sigma clip the data to remove the
994 # turnoff points
995 cleanDataVecMaArray = sigma_clip(
996 detrendedDataVec,
997 sigma=self.config.turnoffFinderSigmaClip,
998 maxiters=self.config.turnoffFinderSigmaClipMaxIters,
999 cenfunc=np.nanmedian,
1000 stdfunc=np.nanstd,
1001 masked=True,
1002 )
1004 # Retrieve the result
1005 good = ~np.ma.getmask(cleanDataVecMaArray)
1006 cleanDataVec = np.ma.getdata(cleanDataVecMaArray)
1008 if cleanDataVec.size == 0:
1009 self.log.warning("No data points after sigma clipping to compute turnoff "
1010 "for amplifier %s. Setting turnoff point to 0 electrons.",
1011 amp.getName())
1012 return 0.0, 0.0
1014 # Get the index of the highest true value in
1015 # the bool array
1016 turnoffIdx = np.argwhere(good)[-1][0]
1017 turnoff = signalVec[turnoffIdx]
1019 if cleanDataVec[good][-1] in ctiRange or turnoffIdx in [0, len(signalVec)-1]:
1020 self.log.warning("Turnoff point is at the edge of the allowed range for "
1021 "amplifier %s.", amp.getName())
1023 self.log.info("Amp %s: There are %d/%d data points left to determine turnoff point.",
1024 amp.getName(), len(cleanDataVec[good]), len(dataVec))
1026 # Compute the sampling error as one half the
1027 # difference between the previous and next point.
1028 # Or, if it is the last index, just compute the
1029 # interval.
1030 if turnoffIdx == len(signalVec) - 1:
1031 samplingError = signalVec[turnoffIdx-1] - signalVec[turnoffIdx]
1032 elif turnoffIdx == 0:
1033 samplingError = signalVec[turnoffIdx]
1034 else:
1035 samplingError = (signalVec[turnoffIdx+1] - signalVec[turnoffIdx-1]) / 2.0
1037 return turnoff, np.fabs(samplingError, dtype=np.float64)