Coverage for python/lsst/cp/pipe/deferredCharge.py: 16%
326 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-27 02:56 -0700
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-27 02:56 -0700
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#
22import copy
23import numpy as np
25import lsst.pipe.base as pipeBase
26import lsst.pipe.base.connectionTypes as cT
27import lsst.pex.config as pexConfig
29from lsst.ip.isr import DeferredChargeCalib, SerialTrap
30from lmfit import Minimizer, Parameters
32from ._lookupStaticCalibration import lookupStaticCalibration
35class CpCtiSolveConnections(pipeBase.PipelineTaskConnections,
36 dimensions=("instrument", "detector")):
37 inputMeasurements = cT.Input(
38 name="cpCtiMeas",
39 doc="Input overscan measurements to fit.",
40 storageClass='StructuredDataDict',
41 dimensions=("instrument", "exposure", "detector"),
42 multiple=True,
43 )
44 camera = cT.PrerequisiteInput(
45 name="camera",
46 doc="Camera geometry to use.",
47 storageClass="Camera",
48 dimensions=("instrument", ),
49 lookupFunction=lookupStaticCalibration,
50 isCalibration=True,
51 )
53 outputCalib = cT.Output(
54 name="cpCtiCalib",
55 doc="Output CTI calibration.",
56 storageClass="IsrCalib",
57 dimensions=("instrument", "detector"),
58 )
61class CpCtiSolveConfig(pipeBase.PipelineTaskConfig,
62 pipelineConnections=CpCtiSolveConnections):
63 """Configuration for the CTI combination.
64 """
65 maxImageMean = pexConfig.Field(
66 dtype=float,
67 default=150000.0,
68 doc="Upper limit on acceptable image flux mean.",
69 )
70 localOffsetColumnRange = pexConfig.ListField(
71 dtype=int,
72 default=[3, 13],
73 doc="First and last overscan column to use for local offset effect.",
74 )
76 maxSignalForCti = pexConfig.Field(
77 dtype=float,
78 default=10000.0,
79 doc="Upper flux limit to use for CTI fit.",
80 )
81 globalCtiColumnRange = pexConfig.ListField(
82 dtype=int,
83 default=[1, 2],
84 doc="First and last overscan column to use for global CTI fit.",
85 )
87 trapColumnRange = pexConfig.ListField(
88 dtype=int,
89 default=[1, 20],
90 doc="First and last overscan column to use for serial trap fit.",
91 )
93 fitError = pexConfig.Field(
94 # This gives the error on the mean in a given column, and so
95 # is expected to be $RN / sqrt(N_rows)$.
96 dtype=float,
97 default=7.0/np.sqrt(2000),
98 doc="Error to use during parameter fitting.",
99 )
102class CpCtiSolveTask(pipeBase.PipelineTask,
103 pipeBase.CmdLineTask):
104 """Combine CTI measurements to a final calibration.
106 This task uses the extended pixel edge response (EPER) method as
107 described by Snyder et al. 2021, Journal of Astronimcal
108 Telescopes, Instruments, and Systems, 7,
109 048002. doi:10.1117/1.JATIS.7.4.048002
110 """
112 ConfigClass = CpCtiSolveConfig
113 _DefaultName = 'cpCtiSolve'
115 def __init__(self, **kwargs):
116 super().__init__(**kwargs)
117 self.allowDebug = True
119 def runQuantum(self, butlerQC, inputRefs, outputRefs):
120 inputs = butlerQC.get(inputRefs)
122 dimensions = [exp.dataId.byName() for exp in inputRefs.inputMeasurements]
123 inputs['inputDims'] = dimensions
125 outputs = self.run(**inputs)
126 butlerQC.put(outputs, outputRefs)
128 def run(self, inputMeasurements, camera, inputDims):
129 """Solve for charge transfer inefficiency from overscan measurements.
131 Parameters
132 ----------
133 inputMeasurements : `list` [`dict`]
134 List of overscan measurements from each input exposure.
135 Each dictionary is nested within a top level 'CTI' key,
136 with measurements organized by amplifier name, containing
137 keys:
139 ``"FIRST_MEAN"``
140 Mean value of first image column (`float`).
141 ``"LAST_MEAN"``
142 Mean value of last image column (`float`).
143 ``"IMAGE_MEAN"``
144 Mean value of the entire image region (`float`).
145 ``"OVERSCAN_COLUMNS"``
146 List of overscan column indicies (`list` [`int`]).
147 ``"OVERSCAN_VALUES"``
148 List of overscan column means (`list` [`float`]).
149 camera : `lsst.afw.cameraGeom.Camera`
150 Camera geometry to use to find detectors.
151 inputDims : `list` [`dict`]
152 List of input dimensions from each input exposure.
154 Returns
155 -------
156 results : `lsst.pipe.base.Struct`
157 Result struct containing:
159 ``outputCalib``
160 Final CTI calibration data
161 (`lsst.ip.isr.DeferredChargeCalib`).
163 Raises
164 ------
165 RuntimeError
166 Raised if data from multiple detectors are passed in.
167 """
168 detectorSet = set([d['detector'] for d in inputDims])
169 if len(detectorSet) != 1:
170 raise RuntimeError("Inputs for too many detectors passed.")
171 detectorId = detectorSet.pop()
172 detector = camera[detectorId]
174 # Initialize with detector.
175 calib = DeferredChargeCalib(camera=camera, detector=detector)
177 localCalib = self.solveLocalOffsets(inputMeasurements, calib, detector)
179 globalCalib = self.solveGlobalCti(inputMeasurements, localCalib, detector)
181 finalCalib = self.findTraps(inputMeasurements, globalCalib, detector)
183 return pipeBase.Struct(
184 outputCalib=finalCalib,
185 )
187 def solveLocalOffsets(self, inputMeasurements, calib, detector):
188 """Solve for local (pixel-to-pixel) electronic offsets.
190 This method fits for \tau_L, the local electronic offset decay
191 time constant, and A_L, the local electronic offset constant
192 of proportionality.
194 Parameters
195 ----------
196 inputMeasurements : `list` [`dict`]
197 List of overscan measurements from each input exposure.
198 Each dictionary is nested within a top level 'CTI' key,
199 with measurements organized by amplifier name, containing
200 keys:
202 ``"FIRST_MEAN"``
203 Mean value of first image column (`float`).
204 ``"LAST_MEAN"``
205 Mean value of last image column (`float`).
206 ``"IMAGE_MEAN"``
207 Mean value of the entire image region (`float`).
208 ``"OVERSCAN_COLUMNS"``
209 List of overscan column indicies (`list` [`int`]).
210 ``"OVERSCAN_VALUES"``
211 List of overscan column means (`list` [`float`]).
212 calib : `lsst.ip.isr.DeferredChargeCalib`
213 Calibration to populate with values.
214 detector : `lsst.afw.cameraGeom.Detector`
215 Detector object containing the geometry information for
216 the amplifiers.
218 Returns
219 -------
220 calib : `lsst.ip.isr.DeferredChargeCalib`
221 Populated calibration.
223 Notes
224 -----
225 The original CTISIM code uses a data model in which the
226 "overscan" consists of the standard serial overscan bbox with
227 the values for the last imaging data column prepended to that
228 list. This version of the code keeps the overscan and imaging
229 sections separate, and so a -1 offset is needed to ensure that
230 the same columns are used for fitting between this code and
231 CTISIM. This offset removes that last imaging data column
232 from the count.
233 """
234 # Range to fit. These are in "camera" coordinates, and so
235 # need to have the count for last image column removed.
236 start, stop = self.config.localOffsetColumnRange
237 start -= 1
238 stop -= 1
240 # Loop over amps/inputs, fitting those columns from
241 # "non-saturated" inputs.
242 for amp in detector.getAmplifiers():
243 ampName = amp.getName()
245 # Number of serial shifts.
246 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
248 # The signal is the mean intensity of each input, and the
249 # data are the overscan columns to fit. For detectors
250 # with non-zero CTI, the charge from the imaging region
251 # leaks into the overscan region.
252 signal = []
253 data = []
254 Nskipped = 0
255 for exposureEntry in inputMeasurements:
256 exposureDict = exposureEntry['CTI']
257 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean:
258 signal.append(exposureDict[ampName]['IMAGE_MEAN'])
259 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1])
260 else:
261 Nskipped += 1
262 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxImageMean}.")
264 signal = np.array(signal)
265 data = np.array(data)
267 ind = signal.argsort()
268 signal = signal[ind]
269 data = data[ind]
271 params = Parameters()
272 params.add('ctiexp', value=-6, min=-7, max=-5, vary=False)
273 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False)
274 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False)
275 params.add('emissiontime', value=0.4, min=0.1, max=1.0, vary=False)
276 params.add('driftscale', value=0.00022, min=0., max=0.001, vary=True)
277 params.add('decaytime', value=2.4, min=0.1, max=4.0, vary=True)
279 model = SimpleModel()
280 minner = Minimizer(model.difference, params,
281 fcn_args=(signal, data, self.config.fitError, nCols),
282 fcn_kws={'start': start, 'stop': stop})
283 result = minner.minimize()
285 # Save results for the drift scale and decay time.
286 if not result.success:
287 self.log.warning("Electronics fitting failure for amplifier %s.", ampName)
289 calib.globalCti[ampName] = 10**result.params['ctiexp']
290 calib.driftScale[ampName] = result.params['driftscale'].value if result.success else 0.0
291 calib.decayTime[ampName] = result.params['decaytime'].value if result.success else 2.4
292 self.log.info("CTI Local Fit %s: cti: %g decayTime: %g driftScale %g",
293 ampName, calib.globalCti[ampName], calib.decayTime[ampName],
294 calib.driftScale[ampName])
295 return calib
297 def solveGlobalCti(self, inputMeasurements, calib, detector):
298 """Solve for global CTI constant.
300 This method solves for the mean global CTI, b.
302 Parameters
303 ----------
304 inputMeasurements : `list` [`dict`]
305 List of overscan measurements from each input exposure.
306 Each dictionary is nested within a top level 'CTI' key,
307 with measurements organized by amplifier name, containing
308 keys:
310 ``"FIRST_MEAN"``
311 Mean value of first image column (`float`).
312 ``"LAST_MEAN"``
313 Mean value of last image column (`float`).
314 ``"IMAGE_MEAN"``
315 Mean value of the entire image region (`float`).
316 ``"OVERSCAN_COLUMNS"``
317 List of overscan column indicies (`list` [`int`]).
318 ``"OVERSCAN_VALUES"``
319 List of overscan column means (`list` [`float`]).
320 calib : `lsst.ip.isr.DeferredChargeCalib`
321 Calibration to populate with values.
322 detector : `lsst.afw.cameraGeom.Detector`
323 Detector object containing the geometry information for
324 the amplifiers.
326 Returns
327 -------
328 calib : `lsst.ip.isr.DeferredChargeCalib`
329 Populated calibration.
331 Notes
332 -----
333 The original CTISIM code uses a data model in which the
334 "overscan" consists of the standard serial overscan bbox with
335 the values for the last imaging data column prepended to that
336 list. This version of the code keeps the overscan and imaging
337 sections separate, and so a -1 offset is needed to ensure that
338 the same columns are used for fitting between this code and
339 CTISIM. This offset removes that last imaging data column
340 from the count.
341 """
342 # Range to fit. These are in "camera" coordinates, and so
343 # need to have the count for last image column removed.
344 start, stop = self.config.globalCtiColumnRange
345 start -= 1
346 stop -= 1
348 # Loop over amps/inputs, fitting those columns from
349 # "non-saturated" inputs.
350 for amp in detector.getAmplifiers():
351 ampName = amp.getName()
353 # Number of serial shifts.
354 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
356 # The signal is the mean intensity of each input, and the
357 # data are the overscan columns to fit. For detectors
358 # with non-zero CTI, the charge from the imaging region
359 # leaks into the overscan region.
360 signal = []
361 data = []
362 Nskipped = 0
363 for exposureEntry in inputMeasurements:
364 exposureDict = exposureEntry['CTI']
365 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxSignalForCti:
366 signal.append(exposureDict[ampName]['IMAGE_MEAN'])
367 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1])
368 else:
369 Nskipped += 1
370 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxSignalForCti}.")
372 signal = np.array(signal)
373 data = np.array(data)
375 ind = signal.argsort()
376 signal = signal[ind]
377 data = data[ind]
379 # CTI test. This looks at the charge that has leaked into
380 # the first few columns of the overscan.
381 overscan1 = data[:, 0]
382 overscan2 = data[:, 1]
383 test = (np.array(overscan1) + np.array(overscan2))/(nCols*np.array(signal))
384 testResult = np.median(test) > 5.E-6
385 self.log.info("Estimate of CTI test is %f for amp %s, %s.", np.median(test), ampName,
386 "full fitting will be performed" if testResult else
387 "only global CTI fitting will be performed")
389 self.debugView(ampName, signal, test)
391 params = Parameters()
392 params.add('ctiexp', value=-6, min=-7, max=-5, vary=True)
393 params.add('trapsize', value=5.0 if testResult else 0.0, min=0.0, max=30.,
394 vary=True if testResult else False)
395 params.add('scaling', value=0.08, min=0.0, max=1.0,
396 vary=True if testResult else False)
397 params.add('emissiontime', value=0.35, min=0.1, max=1.0,
398 vary=True if testResult else False)
399 params.add('driftscale', value=calib.driftScale[ampName], min=0., max=0.001, vary=False)
400 params.add('decaytime', value=calib.decayTime[ampName], min=0.1, max=4.0, vary=False)
402 model = SimulatedModel()
403 minner = Minimizer(model.difference, params,
404 fcn_args=(signal, data, self.config.fitError, nCols, amp),
405 fcn_kws={'start': start, 'stop': stop, 'trap_type': 'linear'})
406 result = minner.minimize()
408 # Only the global CTI term is retained from this fit.
409 calib.globalCti[ampName] = 10**result.params['ctiexp'].value
410 self.log.info("CTI Global Cti %s: cti: %g decayTime: %g driftScale %g",
411 ampName, calib.globalCti[ampName], calib.decayTime[ampName],
412 calib.driftScale[ampName])
414 return calib
416 def debugView(self, ampName, signal, test):
417 """Debug method for global CTI test value.
419 Parameters
420 ----------
421 ampName : `str`
422 Name of the amp for plot title.
423 signal : `list` [`float`]
424 Image means for the input exposures.
425 test : `list` [`float`]
426 CTI test value to plot.
427 """
428 import lsstDebug
429 if not lsstDebug.Info(__name__).display:
430 return
431 if not self.allowDebug:
432 return
434 import matplotlib.pyplot as plot
435 figure = plot.figure(1)
436 figure.clear()
437 plot.xscale('log', base=10.0)
438 plot.yscale('log', base=10.0)
439 plot.xlabel('Flat Field Signal [e-?]')
440 plot.ylabel('Serial CTI')
441 plot.title(ampName)
442 plot.plot(signal, test)
444 figure.show()
445 prompt = "Press Enter or c to continue [chp]..."
446 while True:
447 ans = input(prompt).lower()
448 if ans in ("", " ", "c",):
449 break
450 elif ans in ("p", ):
451 import pdb
452 pdb.set_trace()
453 elif ans in ('x', ):
454 self.allowDebug = False
455 break
456 elif ans in ("h", ):
457 print("[h]elp [c]ontinue [p]db e[x]itDebug")
458 plot.close()
460 def findTraps(self, inputMeasurements, calib, detector):
461 """Solve for serial trap parameters.
463 Parameters
464 ----------
465 inputMeasurements : `list` [`dict`]
466 List of overscan measurements from each input exposure.
467 Each dictionary is nested within a top level 'CTI' key,
468 with measurements organized by amplifier name, containing
469 keys:
471 ``"FIRST_MEAN"``
472 Mean value of first image column (`float`).
473 ``"LAST_MEAN"``
474 Mean value of last image column (`float`).
475 ``"IMAGE_MEAN"``
476 Mean value of the entire image region (`float`).
477 ``"OVERSCAN_COLUMNS"``
478 List of overscan column indicies (`list` [`int`]).
479 ``"OVERSCAN_VALUES"``
480 List of overscan column means (`list` [`float`]).
481 calib : `lsst.ip.isr.DeferredChargeCalib`
482 Calibration to populate with values.
483 detector : `lsst.afw.cameraGeom.Detector`
484 Detector object containing the geometry information for
485 the amplifiers.
487 Returns
488 -------
489 calib : `lsst.ip.isr.DeferredChargeCalib`
490 Populated calibration.
492 Notes
493 -----
494 The original CTISIM code uses a data model in which the
495 "overscan" consists of the standard serial overscan bbox with
496 the values for the last imaging data column prepended to that
497 list. This version of the code keeps the overscan and imaging
498 sections separate, and so a -1 offset is needed to ensure that
499 the same columns are used for fitting between this code and
500 CTISIM. This offset removes that last imaging data column
501 from the count.
502 """
503 # Range to fit. These are in "camera" coordinates, and so
504 # need to have the count for last image column removed.
505 start, stop = self.config.trapColumnRange
506 start -= 1
507 stop -= 1
509 # Loop over amps/inputs, fitting those columns from
510 # "non-saturated" inputs.
511 for amp in detector.getAmplifiers():
512 ampName = amp.getName()
514 # Number of serial shifts.
515 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
517 # The signal is the mean intensity of each input, and the
518 # data are the overscan columns to fit. The new_signal is
519 # the mean in the last image column. Any serial trap will
520 # take charge from this column, and deposit it into the
521 # overscan columns.
522 signal = []
523 data = []
524 new_signal = []
525 Nskipped = 0
526 for exposureEntry in inputMeasurements:
527 exposureDict = exposureEntry['CTI']
528 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean:
529 signal.append(exposureDict[ampName]['IMAGE_MEAN'])
530 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1])
531 new_signal.append(exposureDict[ampName]['LAST_MEAN'])
532 else:
533 Nskipped += 1
534 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxSignalForCti}.")
536 signal = np.array(signal)
537 data = np.array(data)
538 new_signal = np.array(new_signal)
540 ind = signal.argsort()
541 signal = signal[ind]
542 data = data[ind]
543 new_signal = new_signal[ind]
545 # In the absense of any trap, the model results using the
546 # parameters already determined will match the observed
547 # overscan results.
548 params = Parameters()
549 params.add('ctiexp', value=np.log10(calib.globalCti[ampName]),
550 min=-7, max=-5, vary=False)
551 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False)
552 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False)
553 params.add('emissiontime', value=0.35, min=0.1, max=1.0, vary=False)
554 params.add('driftscale', value=calib.driftScale[ampName],
555 min=0.0, max=0.001, vary=False)
556 params.add('decaytime', value=calib.decayTime[ampName],
557 min=0.1, max=4.0, vary=False)
559 model = SimpleModel.model_results(params, signal, nCols,
560 start=start, stop=stop)
562 # Evaluating trap: the difference between the model and
563 # observed data.
564 res = np.sum((data-model)[:, :3], axis=1)
566 # Create spline model for the trap, using the residual
567 # between data and model as a function of the last image
568 # column mean (new_signal) scaled by (1 - A_L).
569 new_signal = np.asarray((1 - calib.driftScale[ampName])*new_signal, dtype=np.float64)
570 x = new_signal
571 y = np.maximum(0, res)
573 # Pad left with ramp
574 y = np.pad(y, (10, 0), 'linear_ramp', end_values=(0, 0))
575 x = np.pad(x, (10, 0), 'linear_ramp', end_values=(0, 0))
577 # Pad right with constant
578 y = np.pad(y, (1, 1), 'constant', constant_values=(0, y[-1]))
579 x = np.pad(x, (1, 1), 'constant', constant_values=(-1, 200000.))
581 trap = SerialTrap(20000.0, 0.4, 1, 'spline', np.concatenate((x, y)).tolist())
582 calib.serialTraps[ampName] = trap
584 return calib
587class OverscanModel:
588 """Base class for handling model/data fit comparisons.
590 This handles all of the methods needed for the lmfit Minimizer to
591 run.
592 """
594 @staticmethod
595 def model_results(params, signal, num_transfers, start=1, stop=10):
596 """Generate a realization of the overscan model, using the specified
597 fit parameters and input signal.
599 Parameters
600 ----------
601 params : `lmfit.Parameters`
602 Object containing the model parameters.
603 signal : `np.ndarray`, (nMeasurements)
604 Array of image means.
605 num_transfers : `int`
606 Number of serial transfers that the charge undergoes.
607 start : `int`, optional
608 First overscan column to fit. This number includes the
609 last imaging column, and needs to be adjusted by one when
610 using the overscan bounding box.
611 stop : `int`, optional
612 Last overscan column to fit. This number includes the
613 last imaging column, and needs to be adjusted by one when
614 using the overscan bounding box.
616 Returns
617 -------
618 results : `np.ndarray`, (nMeasurements, nCols)
619 Model results.
620 """
621 raise NotImplementedError("Subclasses must implement the model calculation.")
623 def loglikelihood(self, params, signal, data, error, *args, **kwargs):
624 """Calculate log likelihood of the model.
626 Parameters
627 ----------
628 params : `lmfit.Parameters`
629 Object containing the model parameters.
630 signal : `np.ndarray`, (nMeasurements)
631 Array of image means.
632 data : `np.ndarray`, (nMeasurements, nCols)
633 Array of overscan column means from each measurement.
634 error : `float`
635 Fixed error value.
636 *args :
637 Additional position arguments.
638 **kwargs :
639 Additional keyword arguments.
641 Returns
642 -------
643 logL : `float`
644 The log-likelihood of the observed data given the model
645 parameters.
646 """
647 model_results = self.model_results(params, signal, *args, **kwargs)
649 inv_sigma2 = 1.0/(error**2.0)
650 diff = model_results - data
652 return -0.5*(np.sum(inv_sigma2*(diff)**2.))
654 def negative_loglikelihood(self, params, signal, data, error, *args, **kwargs):
655 """Calculate negative log likelihood of the model.
657 Parameters
658 ----------
659 params : `lmfit.Parameters`
660 Object containing the model parameters.
661 signal : `np.ndarray`, (nMeasurements)
662 Array of image means.
663 data : `np.ndarray`, (nMeasurements, nCols)
664 Array of overscan column means from each measurement.
665 error : `float`
666 Fixed error value.
667 *args :
668 Additional position arguments.
669 **kwargs :
670 Additional keyword arguments.
672 Returns
673 -------
674 negativelogL : `float`
675 The negative log-likelihood of the observed data given the
676 model parameters.
677 """
678 ll = self.loglikelihood(params, signal, data, error, *args, **kwargs)
680 return -ll
682 def rms_error(self, params, signal, data, error, *args, **kwargs):
683 """Calculate RMS error between model and data.
685 Parameters
686 ----------
687 params : `lmfit.Parameters`
688 Object containing the model parameters.
689 signal : `np.ndarray`, (nMeasurements)
690 Array of image means.
691 data : `np.ndarray`, (nMeasurements, nCols)
692 Array of overscan column means from each measurement.
693 error : `float`
694 Fixed error value.
695 *args :
696 Additional position arguments.
697 **kwargs :
698 Additional keyword arguments.
700 Returns
701 -------
702 rms : `float`
703 The rms error between the model and input data.
704 """
705 model_results = self.model_results(params, signal, *args, **kwargs)
707 diff = model_results - data
708 rms = np.sqrt(np.mean(np.square(diff)))
710 return rms
712 def difference(self, params, signal, data, error, *args, **kwargs):
713 """Calculate the flattened difference array between model and data.
715 Parameters
716 ----------
717 params : `lmfit.Parameters`
718 Object containing the model parameters.
719 signal : `np.ndarray`, (nMeasurements)
720 Array of image means.
721 data : `np.ndarray`, (nMeasurements, nCols)
722 Array of overscan column means from each measurement.
723 error : `float`
724 Fixed error value.
725 *args :
726 Additional position arguments.
727 **kwargs :
728 Additional keyword arguments.
730 Returns
731 -------
732 difference : `np.ndarray`, (nMeasurements*nCols)
733 The rms error between the model and input data.
734 """
735 model_results = self.model_results(params, signal, *args, **kwargs)
736 diff = (model_results-data).flatten()
738 return diff
741class SimpleModel(OverscanModel):
742 """Simple analytic overscan model."""
744 @staticmethod
745 def model_results(params, signal, num_transfers, start=1, stop=10):
746 """Generate a realization of the overscan model, using the specified
747 fit parameters and input signal.
749 Parameters
750 ----------
751 params : `lmfit.Parameters`
752 Object containing the model parameters.
753 signal : `np.ndarray`, (nMeasurements)
754 Array of image means.
755 num_transfers : `int`
756 Number of serial transfers that the charge undergoes.
757 start : `int`, optional
758 First overscan column to fit. This number includes the
759 last imaging column, and needs to be adjusted by one when
760 using the overscan bounding box.
761 stop : `int`, optional
762 Last overscan column to fit. This number includes the
763 last imaging column, and needs to be adjusted by one when
764 using the overscan bounding box.
766 Returns
767 -------
768 res : `np.ndarray`, (nMeasurements, nCols)
769 Model results.
770 """
771 v = params.valuesdict()
772 v['cti'] = 10**v['ctiexp']
774 # Adjust column numbering to match DM overscan bbox.
775 start += 1
776 stop += 1
778 x = np.arange(start, stop+1)
779 res = np.zeros((signal.shape[0], x.shape[0]))
781 for i, s in enumerate(signal):
782 # This is largely equivalent to equation 2. The minimum
783 # indicates that a trap cannot emit more charge than is
784 # available, nor can it emit more charge than it can hold.
785 # This scales the exponential release of charge from the
786 # trap. The next term defines the contribution from the
787 # global CTI at each pixel transfer, and the final term
788 # includes the contribution from local CTI effects.
789 res[i, :] = (np.minimum(v['trapsize'], s*v['scaling'])
790 * (np.exp(1/v['emissiontime']) - 1.0)
791 * np.exp(-x/v['emissiontime'])
792 + s*num_transfers*v['cti']**x
793 + v['driftscale']*s*np.exp(-x/float(v['decaytime'])))
795 return res
798class SimulatedModel(OverscanModel):
799 """Simulated overscan model."""
801 @staticmethod
802 def model_results(params, signal, num_transfers, amp, start=1, stop=10, trap_type=None):
803 """Generate a realization of the overscan model, using the specified
804 fit parameters and input signal.
806 Parameters
807 ----------
808 params : `lmfit.Parameters`
809 Object containing the model parameters.
810 signal : `np.ndarray`, (nMeasurements)
811 Array of image means.
812 num_transfers : `int`
813 Number of serial transfers that the charge undergoes.
814 amp : `lsst.afw.cameraGeom.Amplifier`
815 Amplifier to use for geometry information.
816 start : `int`, optional
817 First overscan column to fit. This number includes the
818 last imaging column, and needs to be adjusted by one when
819 using the overscan bounding box.
820 stop : `int`, optional
821 Last overscan column to fit. This number includes the
822 last imaging column, and needs to be adjusted by one when
823 using the overscan bounding box.
824 trap_type : `str`, optional
825 Type of trap model to use.
827 Returns
828 -------
829 results : `np.ndarray`, (nMeasurements, nCols)
830 Model results.
831 """
832 v = params.valuesdict()
834 # Adjust column numbering to match DM overscan bbox.
835 start += 1
836 stop += 1
838 # Electronics effect optimization
839 output_amplifier = FloatingOutputAmplifier(1.0, v['driftscale'], v['decaytime'])
841 # CTI optimization
842 v['cti'] = 10**v['ctiexp']
844 # Trap type for optimization
845 if trap_type is None:
846 trap = None
847 elif trap_type == 'linear':
848 trap = SerialTrap(v['trapsize'], v['emissiontime'], 1, 'linear',
849 [v['scaling']])
850 elif trap_type == 'logistic':
851 trap = SerialTrap(v['trapsize'], v['emissiontime'], 1, 'logistic',
852 [v['f0'], v['k']])
853 else:
854 raise ValueError('Trap type must be linear or logistic or None')
856 # Simulate ramp readout
857 imarr = np.zeros((signal.shape[0], amp.getRawDataBBox().getWidth()))
858 ramp = SegmentSimulator(imarr, amp.getRawSerialPrescanBBox().getWidth(), output_amplifier,
859 cti=v['cti'], traps=trap)
860 ramp.ramp_exp(signal)
861 model_results = ramp.readout(serial_overscan_width=amp.getRawSerialOverscanBBox().getWidth(),
862 parallel_overscan_width=0)
864 ncols = amp.getRawSerialPrescanBBox().getWidth() + amp.getRawDataBBox().getWidth()
866 return model_results[:, ncols+start-1:ncols+stop]
869class SegmentSimulator:
870 """Controls the creation of simulated segment images.
872 Parameters
873 ----------
874 imarr : `np.ndarray` (nx, ny)
875 Image data array.
876 prescan_width : `int`
877 Number of serial prescan columns.
878 output_amplifier : `lsst.cp.pipe.FloatingOutputAmplifier`
879 An object holding the gain, read noise, and global_offset.
880 cti : `float`
881 Global CTI value.
882 traps : `list` [`lsst.ip.isr.SerialTrap`]
883 Serial traps to simulate.
884 """
886 def __init__(self, imarr, prescan_width, output_amplifier, cti=0.0, traps=None):
887 # Image array geometry
888 self.prescan_width = prescan_width
889 self.ny, self.nx = imarr.shape
891 self.segarr = np.zeros((self.ny, self.nx+prescan_width))
892 self.segarr[:, prescan_width:] = imarr
894 # Serial readout information
895 self.output_amplifier = output_amplifier
896 if isinstance(cti, np.ndarray):
897 raise ValueError("cti must be single value, not an array.")
898 self.cti = cti
900 self.serial_traps = None
901 self.do_trapping = False
902 if traps is not None:
903 if not isinstance(traps, list):
904 traps = [traps]
905 for trap in traps:
906 self.add_trap(trap)
908 def add_trap(self, serial_trap):
909 """Add a trap to the serial register.
911 Parameters
912 ----------
913 serial_trap : `lsst.ip.isr.SerialTrap`
914 The trap to add.
915 """
916 try:
917 self.serial_traps.append(serial_trap)
918 except AttributeError:
919 self.serial_traps = [serial_trap]
920 self.do_trapping = True
922 def ramp_exp(self, signal_list):
923 """Simulate an image with varying flux illumination per row.
925 This method simulates a segment image where the signal level
926 increases along the horizontal direction, according to the
927 provided list of signal levels.
929 Parameters
930 ----------
931 signal_list : `list` [`float`]
932 List of signal levels.
934 Raises
935 ------
936 ValueError
937 Raised if the length of the signal list does not equal the
938 number of rows.
939 """
940 if len(signal_list) != self.ny:
941 raise ValueError("Signal list does not match row count.")
943 ramp = np.tile(signal_list, (self.nx, 1)).T
944 self.segarr[:, self.prescan_width:] += ramp
946 def readout(self, serial_overscan_width=10, parallel_overscan_width=0):
947 """Simulate serial readout of the segment image.
949 This method performs the serial readout of a segment image
950 given the appropriate SerialRegister object and the properties
951 of the ReadoutAmplifier. Additional arguments can be provided
952 to account for the number of desired overscan transfers. The
953 result is a simulated final segment image, in ADU.
955 Parameters
956 ----------
957 serial_overscan_width : `int`, optional
958 Number of serial overscan columns.
959 parallel_overscan_width : `int`, optional
960 Number of parallel overscan rows.
962 Returns
963 -------
964 result : `np.ndarray` (nx, ny)
965 Simulated image, including serial prescan, serial
966 overscan, and parallel overscan regions.
967 """
968 # Create output array
969 iy = int(self.ny + parallel_overscan_width)
970 ix = int(self.nx + self.prescan_width + serial_overscan_width)
971 image = np.random.normal(loc=self.output_amplifier.global_offset,
972 scale=self.output_amplifier.noise,
973 size=(iy, ix))
974 free_charge = copy.deepcopy(self.segarr)
976 # Set flow control parameters
977 do_trapping = self.do_trapping
978 cti = self.cti
980 offset = np.zeros(self.ny)
981 cte = 1 - cti
982 if do_trapping:
983 for trap in self.serial_traps:
984 trap.initialize(self.ny, self.nx, self.prescan_width)
986 for i in range(ix):
987 # Trap capture
988 if do_trapping:
989 for trap in self.serial_traps:
990 captured_charge = trap.trap_charge(free_charge)
991 free_charge -= captured_charge
993 # Pixel-to-pixel proportional loss
994 transferred_charge = free_charge*cte
995 deferred_charge = free_charge*cti
997 # Pixel transfer and readout
998 offset = self.output_amplifier.local_offset(offset,
999 transferred_charge[:, 0])
1000 image[:iy-parallel_overscan_width, i] += transferred_charge[:, 0] + offset
1002 free_charge = np.pad(transferred_charge, ((0, 0), (0, 1)),
1003 mode='constant')[:, 1:] + deferred_charge
1005 # Trap emission
1006 if do_trapping:
1007 for trap in self.serial_traps:
1008 released_charge = trap.release_charge()
1009 free_charge += released_charge
1011 return image/float(self.output_amplifier.gain)
1014class FloatingOutputAmplifier:
1015 """Object representing the readout amplifier of a single channel.
1017 Parameters
1018 ----------
1019 gain : `float`
1020 Amplifier gain.
1021 scale : `float`
1022 Drift scale for the amplifier.
1023 decay_time : `float`
1024 Decay time for the bias drift.
1025 noise : `float`, optional
1026 Amplifier read noise.
1027 offset : `float`, optional
1028 Global CTI offset.
1029 """
1031 def __init__(self, gain, scale, decay_time, noise=0.0, offset=0.0):
1033 self.gain = gain
1034 self.noise = noise
1035 self.global_offset = offset
1037 self.update_parameters(scale, decay_time)
1039 def local_offset(self, old, signal):
1040 """Calculate local offset hysteresis.
1042 Parameters
1043 ----------
1044 old : `np.ndarray`, (,)
1045 Previous iteration.
1046 signal : `np.ndarray`, (,)
1047 Current column measurements.
1049 Returns
1050 -------
1051 offset : `np.ndarray`
1052 Local offset.
1053 """
1054 new = self.scale*signal
1056 return np.maximum(new, old*np.exp(-1/self.decay_time))
1058 def update_parameters(self, scale, decay_time):
1059 """Update parameter values, if within acceptable values.
1061 Parameters
1062 ----------
1063 scale : `float`
1064 Drift scale for the amplifier.
1065 decay_time : `float`
1066 Decay time for the bias drift.
1068 Raises
1069 ------
1070 ValueError
1071 Raised if the input parameters are out of range.
1072 """
1073 if scale < 0.0:
1074 raise ValueError("Scale must be greater than or equal to 0.")
1075 if np.isnan(scale):
1076 raise ValueError("Scale must be real-valued number, not NaN.")
1077 self.scale = scale
1078 if decay_time <= 0.0:
1079 raise ValueError("Decay time must be greater than 0.")
1080 if np.isnan(decay_time):
1081 raise ValueError("Decay time must be real-valued number, not NaN.")
1082 self.decay_time = decay_time