Coverage for python / lsst / cp / pipe / cpCtiSolve.py: 10%
322 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 00:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 00:22 +0000
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=-12, 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), atol=10**(-5)):
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.
527 if np.isclose(10**result.params["ctiexp"].value, 1e-12, atol=1e-12):
528 self.log.info(f"Global CTI for detector {detector.getId()} ({amp.getName()}) has "
529 "hit the fitter's lower bound.")
531 calib.globalCti[ampName] = 10**result.params['ctiexp'].value
533 self.log.info("CTI Global Cti %s: cti: %g decayTime: %g driftScale %g",
534 ampName, calib.globalCti[ampName], calib.decayTime[ampName],
535 calib.driftScale[ampName])
537 return calib
539 def solveEper(self, inputMeasurements, calib, detector):
540 """Solve for serial and parallel extended pixel edge response (EPER),
541 which is an esimator of CTI defined in Snyder et al. 2021.
543 Parameters
544 ----------
545 inputMeasurements : `list` [`dict`]
546 List of overscan measurements from each input exposure.
547 Each dictionary is nested within a top level "CTI" key,
548 with measurements organized by amplifier name, containing
549 keys:
551 ``"FIRST_COLUMN_MEAN"``
552 Mean value of first image column (`float`).
553 ``"LAST_COLUMN_MEAN"``
554 Mean value of last image column (`float`).
555 ``"IMAGE_MEAN"``
556 Mean value of the entire image region (`float`).
557 ``"SERIAL_OVERSCAN_COLUMNS"``
558 List of overscan column indicies (`list` [`int`]).
559 ``"SERIAL_OVERSCAN_VALUES"``
560 List of overscan column means (`list` [`float`]).
561 ``"PARALLEL_OVERSCAN_ROWS"``
562 List of overscan row indicies (`list` [`int`]).
563 ``"PARALLEL_OVERSCAN_VALUES"``
564 List of overscan row means (`list` [`float`).
565 calib : `lsst.ip.isr.DeferredChargeCalib`
566 Calibration to populate with values.
567 detector : `lsst.afw.cameraGeom.Detector`
568 Detector object containing the geometry information for
569 the amplifiers.
571 Returns
572 -------
573 calib : `lsst.ip.isr.DeferredChargeCalib`
574 Populated calibration.
576 Notes
577 -----
578 The original CTISIM code uses a data model in which the
579 "overscan" consists of the standard serial overscan bbox with
580 the values for the last imaging data column prepended to that
581 list. This version of the code keeps the overscan and imaging
582 sections separate, and so a -1 offset is needed to ensure that
583 the same columns are used for fitting between this code and
584 CTISIM. This offset removes that last imaging data column
585 from the count.
586 """
587 for amp in detector.getAmplifiers():
588 ampName = amp.getName()
589 # Do serial EPER calculation
590 signals, serialEperEstimate = self.calcEper(
591 "SERIAL",
592 inputMeasurements,
593 amp,
594 )
596 # Do parallel EPER calculation
597 signals, parallelEperEstimate = self.calcEper(
598 "PARALLEL",
599 inputMeasurements,
600 amp,
601 )
603 # Calculate the serial and parallel turnoffs
604 serialCtiTurnoff, serialCtiTurnoffSamplingErr = self.calcCtiTurnoff(
605 signals,
606 serialEperEstimate,
607 self.config.serialCtiRange,
608 amp,
609 )
610 parallelCtiTurnoff, parallelCtiTurnoffSamplingErr = self.calcCtiTurnoff(
611 signals,
612 parallelEperEstimate,
613 self.config.parallelCtiRange,
614 amp,
615 )
617 # Output the results
618 self.log.info("Amp %s: Setting serial CTI turnoff to %f +/- %f",
619 amp.getName(), serialCtiTurnoff, serialCtiTurnoffSamplingErr)
620 self.log.info("Amp %s: Setting parallel CTI turnoff to %f +/- %f",
621 amp.getName(), parallelCtiTurnoff, parallelCtiTurnoffSamplingErr)
623 # Save everything to the DeferredChargeCalib
624 calib.signals[ampName] = signals
625 calib.serialEper[ampName] = serialEperEstimate
626 calib.parallelEper[ampName] = parallelEperEstimate
627 calib.serialCtiTurnoff[ampName] = serialCtiTurnoff
628 calib.parallelCtiTurnoff[ampName] = parallelCtiTurnoff
629 calib.serialCtiTurnoffSamplingErr[ampName] = serialCtiTurnoffSamplingErr
630 calib.parallelCtiTurnoffSamplingErr[ampName] = parallelCtiTurnoffSamplingErr
632 return calib
634 def debugView(self, ampName, signal, test):
635 """Debug method for global CTI test value.
637 Parameters
638 ----------
639 ampName : `str`
640 Name of the amp for plot title.
641 signal : `list` [`float`]
642 Image means for the input exposures.
643 test : `list` [`float`]
644 CTI test value to plot.
645 """
646 import lsstDebug
647 if not lsstDebug.Info(__name__).display:
648 return
649 if not self.allowDebug:
650 return
652 import matplotlib.pyplot as plot
653 figure = plot.figure(1)
654 figure.clear()
655 plot.xscale('log', base=10.0)
656 plot.yscale('log', base=10.0)
657 plot.xlabel('Flat Field Signal [e-?]')
658 plot.ylabel('Serial CTI')
659 plot.title(ampName)
660 plot.plot(signal, test)
662 figure.show()
663 prompt = "Press Enter or c to continue [chp]..."
664 while True:
665 ans = input(prompt).lower()
666 if ans in ("", " ", "c",):
667 break
668 elif ans in ("p", ):
669 import pdb
670 pdb.set_trace()
671 elif ans in ('x', ):
672 self.allowDebug = False
673 break
674 elif ans in ("h", ):
675 print("[h]elp [c]ontinue [p]db e[x]itDebug")
676 plot.close()
678 def findTraps(self, inputMeasurements, calib, detector):
679 """Solve for serial trap parameters.
681 Parameters
682 ----------
683 inputMeasurements : `list` [`dict`]
684 List of overscan measurements from each input exposure.
685 Each dictionary is nested within a top level "CTI" key,
686 with measurements organized by amplifier name, containing
687 keys:
689 ``"FIRST_COLUMN_MEAN"``
690 Mean value of first image column (`float`).
691 ``"LAST_COLUMN_MEAN"``
692 Mean value of last image column (`float`).
693 ``"IMAGE_MEAN"``
694 Mean value of the entire image region (`float`).
695 ``"SERIAL_OVERSCAN_COLUMNS"``
696 List of overscan column indicies (`list` [`int`]).
697 ``"SERIAL_OVERSCAN_VALUES"``
698 List of overscan column means (`list` [`float`]).
699 calib : `lsst.ip.isr.DeferredChargeCalib`
700 Calibration to populate with values.
701 detector : `lsst.afw.cameraGeom.Detector`
702 Detector object containing the geometry information for
703 the amplifiers.
705 Returns
706 -------
707 calib : `lsst.ip.isr.DeferredChargeCalib`
708 Populated calibration.
710 Raises
711 ------
712 RuntimeError
713 Raised if no data remains after flux filtering.
715 Notes
716 -----
717 The original CTISIM code uses a data model in which the
718 "overscan" consists of the standard serial overscan bbox with
719 the values for the last imaging data column prepended to that
720 list. This version of the code keeps the overscan and imaging
721 sections separate, and so a -1 offset is needed to ensure that
722 the same columns are used for fitting between this code and
723 CTISIM. This offset removes that last imaging data column
724 from the count.
725 """
726 # Range to fit. These are in "camera" coordinates, and so
727 # need to have the count for last image column removed.
728 start, stop = self.config.trapColumnRange
729 start -= 1
730 stop -= 1
732 # Loop over amps/inputs, fitting those columns from
733 # "non-saturated" inputs.
734 for amp in detector.getAmplifiers():
735 ampName = amp.getName()
737 # Number of serial shifts.
738 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
740 # The signal is the mean intensity of each input, and the
741 # data are the overscan columns to fit. The new_signal is
742 # the mean in the last image column. Any serial trap will
743 # take charge from this column, and deposit it into the
744 # overscan columns.
745 signal = []
746 data = []
747 new_signal = []
748 nSkipped = 0
749 for exposureEntry in inputMeasurements:
750 exposureDict = exposureEntry['CTI']
751 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean:
752 imMean = exposureDict[ampName]['IMAGE_MEAN']
753 sOverscan = exposureDict[ampName]['SERIAL_OVERSCAN_VALUES'][start: stop + 1]
754 lastColumnMean = exposureDict[ampName]['LAST_COLUMN_MEAN']
755 if not np.all(np.isfinite(imMean)) or not np.all(np.isfinite(sOverscan)) \
756 or not np.all(np.isfinite(lastColumnMean)):
757 self.log.warning("NaN detected in an entry for amplifier %s.", ampName)
758 nSkipped += 1
759 else:
760 signal.append(imMean)
761 data.append(sOverscan)
762 new_signal.append(lastColumnMean)
763 else:
764 nSkipped += 1
765 self.log.info("Skipped %d exposures brighter than %f.",
766 nSkipped, self.config.maxImageMean)
767 if len(signal) == 0 or len(data) == 0:
768 # There are no exposures left, so set trap so that
769 # there is no correction
770 self.log.warning("All exposures brighter than config.maxImageMean are excluded. "
771 "Setting zero-sized serial trap for amp %s.", ampName)
772 # Arbitrary trap with no size
773 trap = SerialTrap(0.0, 0.4, 1, 'linear', [1.0])
774 calib.serialTraps[ampName] = trap
775 continue
777 signal = np.array(signal)
778 data = np.array(data)
779 new_signal = np.array(new_signal)
781 ind = signal.argsort()
782 signal = signal[ind]
783 data = data[ind]
784 new_signal = new_signal[ind]
786 # In the absense of any trap, the model results using the
787 # parameters already determined will match the observed
788 # overscan results.
789 params = Parameters()
791 if calib.globalCti[ampName] == 0:
792 globalCtiPower = -18 # Something small
793 else:
794 globalCtiPower = np.log10(calib.globalCti[ampName])
796 params.add('ctiexp', value=globalCtiPower,
797 min=-18, max=-5, vary=False)
798 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False)
799 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False)
800 params.add('emissiontime', value=0.35, min=0.1, max=1.0, vary=False)
801 params.add('driftscale', value=calib.driftScale[ampName],
802 min=0.0, max=0.001, vary=False)
803 params.add('decaytime', value=calib.decayTime[ampName],
804 min=0.1, max=4.0, vary=False)
806 model = SimpleModel.model_results(params, signal, nCols,
807 start=start, stop=stop)
809 # Evaluating trap: the difference between the model and
810 # observed data.
811 res = np.sum((data-model)[:, :3], axis=1)
813 # Create spline model for the trap, using the residual
814 # between data and model as a function of the last image
815 # column mean (new_signal) scaled by (1 - A_L).
816 # Note that this ``spline`` model is actually a piecewise
817 # linear interpolation and not a true spline.
818 new_signal = np.asarray((1 - calib.driftScale[ampName])*new_signal, dtype=np.float64)
819 x = new_signal
820 y = np.maximum(0, res)
822 # Pad left with ramp
823 y = np.pad(y, (10, 0), 'linear_ramp', end_values=(0, 0))
824 x = np.pad(x, (10, 0), 'linear_ramp', end_values=(0, 0))
826 trap = SerialTrap(20000.0, 0.4, 1, 'spline', np.concatenate((x, y)).tolist())
827 calib.serialTraps[ampName] = trap
829 return calib
831 def calcEper(self, mode, inputMeasurements, amp):
832 """Solve for serial or parallel global CTI using the extended
833 pixel edge response (EPER) method.
835 Parameters
836 ----------
837 mode : `str`
838 The orientation of the calculation to perform. Can be
839 either "SERIAL" or "PARALLEL".
840 inputMeasurements : `list` [`dict`]
841 List of overscan measurements from each input exposure.
842 Each dictionary is nested within a top level "CTI" key,
843 with measurements organized by amplifier name, containing
844 keys:
846 ``"FIRST_COLUMN_MEAN"``
847 Mean value of first image column (`float`).
848 ``"LAST_COLUMN_MEAN"``
849 Mean value of last image column (`float`).
850 ``"IMAGE_MEAN"``
851 Mean value of the entire image region (`float`).
852 ``"SERIAL_OVERSCAN_COLUMNS"``
853 List of serial overscan column
854 indicies (`list` [`int`]).
855 ``"SERIAL_OVERSCAN_VALUES"``
856 List of serial overscan column
857 means (`list` [`float`]).
858 ``"PARALLEL_OVERSCAN_ROWS"``
859 List of parallel overscan row
860 indicies (`list` [`int`]).
861 ``"PARALLEL_OVERSCAN_VALUES"``
862 List of parallel overscan row
863 means (`list` [`float`]).
864 calib : `lsst.ip.isr.DeferredChargeCalib`
865 Calibration to populate with values.
866 amp : `lsst.afw.cameraGeom.Amplifier`
867 Amplifier object containing the geometry information for
868 the amplifier.
870 Returns
871 -------
872 calib : `lsst.ip.isr.DeferredChargeCalib`
873 Populated calibration.
875 Raises
876 ------
877 RuntimeError : Raised if no data remains after flux filtering or if
878 the mode string is not one of "SERIAL" or "PARALLEL".
879 """
880 ampName = amp.getName()
882 # First, check if there are input measurements
883 if len(inputMeasurements) == 0:
884 raise RuntimeError("No input measurements to solve for EPER.")
886 # Range to fit. These are in "camera" coordinates, and so
887 # need to have the count for last image column removed.
888 if mode == "SERIAL":
889 start, stop = self.config.globalCtiColumnRange
890 start -= 1
891 stop -= 1
893 # Number of serial shifts = nCols
894 nShifts = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
895 elif mode == "PARALLEL":
896 start, stop = self.config.globalCtiRowRange
897 start -= 1
898 stop -= 1
900 # Number of parallel shifts = nRows
901 nShifts = amp.getRawDataBBox().getHeight()
902 else:
903 raise RuntimeError(f"{mode} is not a known orientation for the EPER calculation.")
905 # The signal is the mean intensity of each input, and the
906 # data are the overscan columns to fit. For detectors
907 # with non-zero CTI, the charge from the imaging region
908 # leaks into the overscan region.
909 signal = []
910 data = []
911 for exposureEntry in inputMeasurements:
912 exposureDict = exposureEntry['CTI']
913 signal.append(exposureDict[ampName]['IMAGE_MEAN'])
914 data.append(exposureDict[ampName][f'{mode}_OVERSCAN_VALUES'][start:stop+1])
916 signal = np.array(signal)
917 data = np.array(data)
919 ind = signal.argsort()
920 signal = signal[ind]
921 data = data[ind]
923 # This looks at the charge that has leaked into
924 # the first few columns or rows of the overscan.
925 overscan1 = data[:, 0]
926 overscan2 = data[:, 1]
927 ctiEstimate = (overscan1 + overscan2)/(nShifts*np.array(signal))
929 return signal, ctiEstimate
931 def calcCtiTurnoff(self, signalVec, dataVec, ctiRange, amp):
932 """Solve for turnoff value in a sequenced dataset.
934 Parameters
935 ----------
936 signalVec : `np.ndarray`
937 Signal values for the dataset. Must be sorted
938 in ascending order.
939 dataVec : `np.ndarray`
940 Data values for the dataset. Must be sorted
941 in ascending order.
942 ctiRange : `list` [`float`]
943 Range of CTI within which to search for the
944 turnoff point.
946 Returns
947 -------
948 turnoff : `float`
949 The turnoff point in the same units as the
950 input signals
951 turnoffSamplingError : `float`
952 The sampling error of the turnoff point, equals
953 turnoff when not enough data points.
955 Notes
956 ------
957 If the data is sparse and does not cover the turnoff region,
958 it will likely just return the highest signal in the dataset.
960 However, it will issue a warning if the turnoff point is at
961 the edge of the search range.
962 """
963 # First, trim the data
964 idxs = (dataVec >= ctiRange[0]) * (dataVec <= ctiRange[1])
965 dataVec = dataVec[idxs]
966 signalVec = signalVec[idxs]
968 # Check for remaining data points
969 if dataVec.size == 0:
970 self.log.warning("No data points after cti range cut to compute turnoff "
971 "for amplifier %s. Setting turnoff point to 0 electrons.",
972 amp.getName())
973 return 0.0, 0.0
975 if dataVec.size < 2:
976 self.log.warning("Insufficient data points after cti range cut to compute turnoff "
977 "for amplifier %s. Setting turnoff point to the maximum signal "
978 "value.", amp.getName())
979 return signalVec[-1], signalVec[-1]
981 # Detrend the data
982 # We will use np.gradient since this method of
983 # detrending turns out to be more practical
984 # than using np.diff, which tends to be noisier.
985 # Besides, this tends to filter out the low
986 # gradient features of the data, particularly
987 # in the parallel turnoff.
988 detrendedDataVec = np.gradient(dataVec)
990 # Sigma clip the data to remove the
991 # turnoff points
992 cleanDataVecMaArray = sigma_clip(
993 detrendedDataVec,
994 sigma=self.config.turnoffFinderSigmaClip,
995 maxiters=self.config.turnoffFinderSigmaClipMaxIters,
996 cenfunc=np.nanmedian,
997 stdfunc=np.nanstd,
998 masked=True,
999 )
1001 # Retrieve the result
1002 good = ~np.ma.getmask(cleanDataVecMaArray)
1003 cleanDataVec = np.ma.getdata(cleanDataVecMaArray)
1005 if cleanDataVec.size == 0:
1006 self.log.warning("No data points after sigma clipping to compute turnoff "
1007 "for amplifier %s. Setting turnoff point to 0 electrons.",
1008 amp.getName())
1009 return 0.0, 0.0
1011 # Get the index of the highest true value in
1012 # the bool array
1013 turnoffIdx = np.argwhere(good)[-1][0]
1014 turnoff = signalVec[turnoffIdx]
1016 if cleanDataVec[good][-1] in ctiRange or turnoffIdx in [0, len(signalVec)-1]:
1017 self.log.warning("Turnoff point is at the edge of the allowed range for "
1018 "amplifier %s.", amp.getName())
1020 self.log.info("Amp %s: There are %d/%d data points left to determine turnoff point.",
1021 amp.getName(), len(cleanDataVec[good]), len(dataVec))
1023 # Compute the sampling error as one half the
1024 # difference between the previous and next point.
1025 # Or, if it is the last index, just compute the
1026 # interval.
1027 if turnoffIdx == len(signalVec) - 1:
1028 samplingError = signalVec[turnoffIdx-1] - signalVec[turnoffIdx]
1029 elif turnoffIdx == 0:
1030 samplingError = signalVec[turnoffIdx]
1031 else:
1032 samplingError = (signalVec[turnoffIdx+1] - signalVec[turnoffIdx-1]) / 2.0
1034 return turnoff, np.fabs(samplingError, dtype=np.float64)