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