Coverage for python/lsst/cp/pipe/deferredCharge.py: 16%
333 statements
« prev ^ index » next coverage.py v6.4.4, created at 2022-09-22 02:51 -0700
« prev ^ index » next coverage.py v6.4.4, created at 2022-09-22 02:51 -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#
22__all__ = ('CpCtiSolveConnections',
23 'CpCtiSolveConfig',
24 'CpCtiSolveTask',
25 'OverscanModel',
26 'SimpleModel',
27 'SimulatedModel',
28 'SegmentSimulator',
29 'FloatingOutputAmplifier',
30 )
32import copy
33import numpy as np
35import lsst.pipe.base as pipeBase
36import lsst.pipe.base.connectionTypes as cT
37import lsst.pex.config as pexConfig
39from lsst.ip.isr import DeferredChargeCalib, SerialTrap
40from lmfit import Minimizer, Parameters
42from ._lookupStaticCalibration import lookupStaticCalibration
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 """Combine CTI measurements to a final calibration.
115 This task uses the extended pixel edge response (EPER) method as
116 described by Snyder et al. 2021, Journal of Astronimcal
117 Telescopes, Instruments, and Systems, 7,
118 048002. doi:10.1117/1.JATIS.7.4.048002
119 """
121 ConfigClass = CpCtiSolveConfig
122 _DefaultName = 'cpCtiSolve'
124 def __init__(self, **kwargs):
125 super().__init__(**kwargs)
126 self.allowDebug = True
128 def runQuantum(self, butlerQC, inputRefs, outputRefs):
129 inputs = butlerQC.get(inputRefs)
131 dimensions = [exp.dataId.byName() for exp in inputRefs.inputMeasurements]
132 inputs['inputDims'] = dimensions
134 outputs = self.run(**inputs)
135 butlerQC.put(outputs, outputRefs)
137 def run(self, inputMeasurements, camera, inputDims):
138 """Solve for charge transfer inefficiency from overscan measurements.
140 Parameters
141 ----------
142 inputMeasurements : `list` [`dict`]
143 List of overscan measurements from each input exposure.
144 Each dictionary is nested within a top level 'CTI' key,
145 with measurements organized by amplifier name, containing
146 keys:
148 ``"FIRST_MEAN"``
149 Mean value of first image column (`float`).
150 ``"LAST_MEAN"``
151 Mean value of last image column (`float`).
152 ``"IMAGE_MEAN"``
153 Mean value of the entire image region (`float`).
154 ``"OVERSCAN_COLUMNS"``
155 List of overscan column indicies (`list` [`int`]).
156 ``"OVERSCAN_VALUES"``
157 List of overscan column means (`list` [`float`]).
158 camera : `lsst.afw.cameraGeom.Camera`
159 Camera geometry to use to find detectors.
160 inputDims : `list` [`dict`]
161 List of input dimensions from each input exposure.
163 Returns
164 -------
165 results : `lsst.pipe.base.Struct`
166 Result struct containing:
168 ``outputCalib``
169 Final CTI calibration data
170 (`lsst.ip.isr.DeferredChargeCalib`).
172 Raises
173 ------
174 RuntimeError
175 Raised if data from multiple detectors are passed in.
176 """
177 detectorSet = set([d['detector'] for d in inputDims])
178 if len(detectorSet) != 1:
179 raise RuntimeError("Inputs for too many detectors passed.")
180 detectorId = detectorSet.pop()
181 detector = camera[detectorId]
183 # Initialize with detector.
184 calib = DeferredChargeCalib(camera=camera, detector=detector)
186 localCalib = self.solveLocalOffsets(inputMeasurements, calib, detector)
188 globalCalib = self.solveGlobalCti(inputMeasurements, localCalib, detector)
190 finalCalib = self.findTraps(inputMeasurements, globalCalib, detector)
192 return pipeBase.Struct(
193 outputCalib=finalCalib,
194 )
196 def solveLocalOffsets(self, inputMeasurements, calib, detector):
197 """Solve for local (pixel-to-pixel) electronic offsets.
199 This method fits for \tau_L, the local electronic offset decay
200 time constant, and A_L, the local electronic offset constant
201 of proportionality.
203 Parameters
204 ----------
205 inputMeasurements : `list` [`dict`]
206 List of overscan measurements from each input exposure.
207 Each dictionary is nested within a top level 'CTI' key,
208 with measurements organized by amplifier name, containing
209 keys:
211 ``"FIRST_MEAN"``
212 Mean value of first image column (`float`).
213 ``"LAST_MEAN"``
214 Mean value of last image column (`float`).
215 ``"IMAGE_MEAN"``
216 Mean value of the entire image region (`float`).
217 ``"OVERSCAN_COLUMNS"``
218 List of overscan column indicies (`list` [`int`]).
219 ``"OVERSCAN_VALUES"``
220 List of overscan column means (`list` [`float`]).
221 calib : `lsst.ip.isr.DeferredChargeCalib`
222 Calibration to populate with values.
223 detector : `lsst.afw.cameraGeom.Detector`
224 Detector object containing the geometry information for
225 the amplifiers.
227 Returns
228 -------
229 calib : `lsst.ip.isr.DeferredChargeCalib`
230 Populated calibration.
232 Raises
233 ------
234 RuntimeError
235 Raised if no data remains after flux filtering.
237 Notes
238 -----
239 The original CTISIM code (https://github.com/Snyder005/ctisim)
240 uses a data model in which the "overscan" consists of the
241 standard serial overscan bbox with the values for the last
242 imaging data column prepended to that list. This version of
243 the code keeps the overscan and imaging sections separate, and
244 so a -1 offset is needed to ensure that the same columns are
245 used for fitting between this code and CTISIM. This offset
246 removes that last imaging data column from the count.
247 """
248 # Range to fit. These are in "camera" coordinates, and so
249 # need to have the count for last image column removed.
250 start, stop = self.config.localOffsetColumnRange
251 start -= 1
252 stop -= 1
254 # Loop over amps/inputs, fitting those columns from
255 # "non-saturated" inputs.
256 for amp in detector.getAmplifiers():
257 ampName = amp.getName()
259 # Number of serial shifts.
260 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
262 # The signal is the mean intensity of each input, and the
263 # data are the overscan columns to fit. For detectors
264 # with non-zero CTI, the charge from the imaging region
265 # leaks into the overscan region.
266 signal = []
267 data = []
268 Nskipped = 0
269 for exposureEntry in inputMeasurements:
270 exposureDict = exposureEntry['CTI']
271 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean:
272 signal.append(exposureDict[ampName]['IMAGE_MEAN'])
273 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1])
274 else:
275 Nskipped += 1
276 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxImageMean}.")
277 if len(signal) == 0 or len(data) == 0:
278 raise RuntimeError("All exposures brighter than config.maxImageMean and excluded.")
280 signal = np.array(signal)
281 data = np.array(data)
283 ind = signal.argsort()
284 signal = signal[ind]
285 data = data[ind]
287 params = Parameters()
288 params.add('ctiexp', value=-6, min=-7, max=-5, vary=False)
289 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False)
290 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False)
291 params.add('emissiontime', value=0.4, min=0.1, max=1.0, vary=False)
292 params.add('driftscale', value=0.00022, min=0., max=0.001, vary=True)
293 params.add('decaytime', value=2.4, min=0.1, max=4.0, vary=True)
295 model = SimpleModel()
296 minner = Minimizer(model.difference, params,
297 fcn_args=(signal, data, self.config.fitError, nCols),
298 fcn_kws={'start': start, 'stop': stop})
299 result = minner.minimize()
301 # Save results for the drift scale and decay time.
302 if not result.success:
303 self.log.warning("Electronics fitting failure for amplifier %s.", ampName)
305 calib.globalCti[ampName] = 10**result.params['ctiexp']
306 calib.driftScale[ampName] = result.params['driftscale'].value if result.success else 0.0
307 calib.decayTime[ampName] = result.params['decaytime'].value if result.success else 2.4
308 self.log.info("CTI Local Fit %s: cti: %g decayTime: %g driftScale %g",
309 ampName, calib.globalCti[ampName], calib.decayTime[ampName],
310 calib.driftScale[ampName])
311 return calib
313 def solveGlobalCti(self, inputMeasurements, calib, detector):
314 """Solve for global CTI constant.
316 This method solves for the mean global CTI, b.
318 Parameters
319 ----------
320 inputMeasurements : `list` [`dict`]
321 List of overscan measurements from each input exposure.
322 Each dictionary is nested within a top level 'CTI' key,
323 with measurements organized by amplifier name, containing
324 keys:
326 ``"FIRST_MEAN"``
327 Mean value of first image column (`float`).
328 ``"LAST_MEAN"``
329 Mean value of last image column (`float`).
330 ``"IMAGE_MEAN"``
331 Mean value of the entire image region (`float`).
332 ``"OVERSCAN_COLUMNS"``
333 List of overscan column indicies (`list` [`int`]).
334 ``"OVERSCAN_VALUES"``
335 List of overscan column means (`list` [`float`]).
336 calib : `lsst.ip.isr.DeferredChargeCalib`
337 Calibration to populate with values.
338 detector : `lsst.afw.cameraGeom.Detector`
339 Detector object containing the geometry information for
340 the amplifiers.
342 Returns
343 -------
344 calib : `lsst.ip.isr.DeferredChargeCalib`
345 Populated calibration.
347 Raises
348 ------
349 RuntimeError
350 Raised if no data remains after flux filtering.
352 Notes
353 -----
354 The original CTISIM code uses a data model in which the
355 "overscan" consists of the standard serial overscan bbox with
356 the values for the last imaging data column prepended to that
357 list. This version of the code keeps the overscan and imaging
358 sections separate, and so a -1 offset is needed to ensure that
359 the same columns are used for fitting between this code and
360 CTISIM. This offset removes that last imaging data column
361 from the count.
362 """
363 # Range to fit. These are in "camera" coordinates, and so
364 # need to have the count for last image column removed.
365 start, stop = self.config.globalCtiColumnRange
366 start -= 1
367 stop -= 1
369 # Loop over amps/inputs, fitting those columns from
370 # "non-saturated" inputs.
371 for amp in detector.getAmplifiers():
372 ampName = amp.getName()
374 # Number of serial shifts.
375 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
377 # The signal is the mean intensity of each input, and the
378 # data are the overscan columns to fit. For detectors
379 # with non-zero CTI, the charge from the imaging region
380 # leaks into the overscan region.
381 signal = []
382 data = []
383 Nskipped = 0
384 for exposureEntry in inputMeasurements:
385 exposureDict = exposureEntry['CTI']
386 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxSignalForCti:
387 signal.append(exposureDict[ampName]['IMAGE_MEAN'])
388 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1])
389 else:
390 Nskipped += 1
391 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxSignalForCti}.")
392 if len(signal) == 0 or len(data) == 0:
393 raise RuntimeError("All exposures brighter than config.maxSignalForCti and excluded.")
395 signal = np.array(signal)
396 data = np.array(data)
398 ind = signal.argsort()
399 signal = signal[ind]
400 data = data[ind]
402 # CTI test. This looks at the charge that has leaked into
403 # the first few columns of the overscan.
404 overscan1 = data[:, 0]
405 overscan2 = data[:, 1]
406 test = (np.array(overscan1) + np.array(overscan2))/(nCols*np.array(signal))
407 testResult = np.median(test) > 5.E-6
408 self.log.info("Estimate of CTI test is %f for amp %s, %s.", np.median(test), ampName,
409 "full fitting will be performed" if testResult else
410 "only global CTI fitting will be performed")
412 self.debugView(ampName, signal, test)
414 params = Parameters()
415 params.add('ctiexp', value=-6, min=-7, max=-5, vary=True)
416 params.add('trapsize', value=5.0 if testResult else 0.0, min=0.0, max=30.,
417 vary=True if testResult else False)
418 params.add('scaling', value=0.08, min=0.0, max=1.0,
419 vary=True if testResult else False)
420 params.add('emissiontime', value=0.35, min=0.1, max=1.0,
421 vary=True if testResult else False)
422 params.add('driftscale', value=calib.driftScale[ampName], min=0., max=0.001, vary=False)
423 params.add('decaytime', value=calib.decayTime[ampName], min=0.1, max=4.0, vary=False)
425 model = SimulatedModel()
426 minner = Minimizer(model.difference, params,
427 fcn_args=(signal, data, self.config.fitError, nCols, amp),
428 fcn_kws={'start': start, 'stop': stop, 'trap_type': 'linear'})
429 result = minner.minimize()
431 # Only the global CTI term is retained from this fit.
432 calib.globalCti[ampName] = 10**result.params['ctiexp'].value
433 self.log.info("CTI Global Cti %s: cti: %g decayTime: %g driftScale %g",
434 ampName, calib.globalCti[ampName], calib.decayTime[ampName],
435 calib.driftScale[ampName])
437 return calib
439 def debugView(self, ampName, signal, test):
440 """Debug method for global CTI test value.
442 Parameters
443 ----------
444 ampName : `str`
445 Name of the amp for plot title.
446 signal : `list` [`float`]
447 Image means for the input exposures.
448 test : `list` [`float`]
449 CTI test value to plot.
450 """
451 import lsstDebug
452 if not lsstDebug.Info(__name__).display:
453 return
454 if not self.allowDebug:
455 return
457 import matplotlib.pyplot as plot
458 figure = plot.figure(1)
459 figure.clear()
460 plot.xscale('log', base=10.0)
461 plot.yscale('log', base=10.0)
462 plot.xlabel('Flat Field Signal [e-?]')
463 plot.ylabel('Serial CTI')
464 plot.title(ampName)
465 plot.plot(signal, test)
467 figure.show()
468 prompt = "Press Enter or c to continue [chp]..."
469 while True:
470 ans = input(prompt).lower()
471 if ans in ("", " ", "c",):
472 break
473 elif ans in ("p", ):
474 import pdb
475 pdb.set_trace()
476 elif ans in ('x', ):
477 self.allowDebug = False
478 break
479 elif ans in ("h", ):
480 print("[h]elp [c]ontinue [p]db e[x]itDebug")
481 plot.close()
483 def findTraps(self, inputMeasurements, calib, detector):
484 """Solve for serial trap parameters.
486 Parameters
487 ----------
488 inputMeasurements : `list` [`dict`]
489 List of overscan measurements from each input exposure.
490 Each dictionary is nested within a top level 'CTI' key,
491 with measurements organized by amplifier name, containing
492 keys:
494 ``"FIRST_MEAN"``
495 Mean value of first image column (`float`).
496 ``"LAST_MEAN"``
497 Mean value of last image column (`float`).
498 ``"IMAGE_MEAN"``
499 Mean value of the entire image region (`float`).
500 ``"OVERSCAN_COLUMNS"``
501 List of overscan column indicies (`list` [`int`]).
502 ``"OVERSCAN_VALUES"``
503 List of overscan column means (`list` [`float`]).
504 calib : `lsst.ip.isr.DeferredChargeCalib`
505 Calibration to populate with values.
506 detector : `lsst.afw.cameraGeom.Detector`
507 Detector object containing the geometry information for
508 the amplifiers.
510 Returns
511 -------
512 calib : `lsst.ip.isr.DeferredChargeCalib`
513 Populated calibration.
515 Raises
516 ------
517 RuntimeError
518 Raised if no data remains after flux filtering.
520 Notes
521 -----
522 The original CTISIM code uses a data model in which the
523 "overscan" consists of the standard serial overscan bbox with
524 the values for the last imaging data column prepended to that
525 list. This version of the code keeps the overscan and imaging
526 sections separate, and so a -1 offset is needed to ensure that
527 the same columns are used for fitting between this code and
528 CTISIM. This offset removes that last imaging data column
529 from the count.
530 """
531 # Range to fit. These are in "camera" coordinates, and so
532 # need to have the count for last image column removed.
533 start, stop = self.config.trapColumnRange
534 start -= 1
535 stop -= 1
537 # Loop over amps/inputs, fitting those columns from
538 # "non-saturated" inputs.
539 for amp in detector.getAmplifiers():
540 ampName = amp.getName()
542 # Number of serial shifts.
543 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth()
545 # The signal is the mean intensity of each input, and the
546 # data are the overscan columns to fit. The new_signal is
547 # the mean in the last image column. Any serial trap will
548 # take charge from this column, and deposit it into the
549 # overscan columns.
550 signal = []
551 data = []
552 new_signal = []
553 Nskipped = 0
554 for exposureEntry in inputMeasurements:
555 exposureDict = exposureEntry['CTI']
556 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean:
557 signal.append(exposureDict[ampName]['IMAGE_MEAN'])
558 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1])
559 new_signal.append(exposureDict[ampName]['LAST_MEAN'])
560 else:
561 Nskipped += 1
562 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxImageMean}.")
563 if len(signal) == 0 or len(data) == 0:
564 raise RuntimeError("All exposures brighter than config.maxImageMean and excluded.")
566 signal = np.array(signal)
567 data = np.array(data)
568 new_signal = np.array(new_signal)
570 ind = signal.argsort()
571 signal = signal[ind]
572 data = data[ind]
573 new_signal = new_signal[ind]
575 # In the absense of any trap, the model results using the
576 # parameters already determined will match the observed
577 # overscan results.
578 params = Parameters()
579 params.add('ctiexp', value=np.log10(calib.globalCti[ampName]),
580 min=-7, max=-5, vary=False)
581 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False)
582 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False)
583 params.add('emissiontime', value=0.35, min=0.1, max=1.0, vary=False)
584 params.add('driftscale', value=calib.driftScale[ampName],
585 min=0.0, max=0.001, vary=False)
586 params.add('decaytime', value=calib.decayTime[ampName],
587 min=0.1, max=4.0, vary=False)
589 model = SimpleModel.model_results(params, signal, nCols,
590 start=start, stop=stop)
592 # Evaluating trap: the difference between the model and
593 # observed data.
594 res = np.sum((data-model)[:, :3], axis=1)
596 # Create spline model for the trap, using the residual
597 # between data and model as a function of the last image
598 # column mean (new_signal) scaled by (1 - A_L).
599 new_signal = np.asarray((1 - calib.driftScale[ampName])*new_signal, dtype=np.float64)
600 x = new_signal
601 y = np.maximum(0, res)
603 # Pad left with ramp
604 y = np.pad(y, (10, 0), 'linear_ramp', end_values=(0, 0))
605 x = np.pad(x, (10, 0), 'linear_ramp', end_values=(0, 0))
607 # Pad right with constant
608 y = np.pad(y, (1, 1), 'constant', constant_values=(0, y[-1]))
609 x = np.pad(x, (1, 1), 'constant', constant_values=(-1, 200000.))
611 trap = SerialTrap(20000.0, 0.4, 1, 'spline', np.concatenate((x, y)).tolist())
612 calib.serialTraps[ampName] = trap
614 return calib
617class OverscanModel:
618 """Base class for handling model/data fit comparisons.
620 This handles all of the methods needed for the lmfit Minimizer to
621 run.
622 """
624 @staticmethod
625 def model_results(params, signal, num_transfers, start=1, stop=10):
626 """Generate a realization of the overscan model, using the specified
627 fit parameters and input signal.
629 Parameters
630 ----------
631 params : `lmfit.Parameters`
632 Object containing the model parameters.
633 signal : `np.ndarray`, (nMeasurements)
634 Array of image means.
635 num_transfers : `int`
636 Number of serial transfers that the charge undergoes.
637 start : `int`, optional
638 First overscan column to fit. This number includes the
639 last imaging column, and needs to be adjusted by one when
640 using the overscan bounding box.
641 stop : `int`, optional
642 Last overscan column to fit. This number includes the
643 last imaging column, and needs to be adjusted by one when
644 using the overscan bounding box.
646 Returns
647 -------
648 results : `np.ndarray`, (nMeasurements, nCols)
649 Model results.
650 """
651 raise NotImplementedError("Subclasses must implement the model calculation.")
653 def loglikelihood(self, params, signal, data, error, *args, **kwargs):
654 """Calculate log likelihood of the model.
656 Parameters
657 ----------
658 params : `lmfit.Parameters`
659 Object containing the model parameters.
660 signal : `np.ndarray`, (nMeasurements)
661 Array of image means.
662 data : `np.ndarray`, (nMeasurements, nCols)
663 Array of overscan column means from each measurement.
664 error : `float`
665 Fixed error value.
666 *args :
667 Additional position arguments.
668 **kwargs :
669 Additional keyword arguments.
671 Returns
672 -------
673 logL : `float`
674 The log-likelihood of the observed data given the model
675 parameters.
676 """
677 model_results = self.model_results(params, signal, *args, **kwargs)
679 inv_sigma2 = 1.0/(error**2.0)
680 diff = model_results - data
682 return -0.5*(np.sum(inv_sigma2*(diff)**2.))
684 def negative_loglikelihood(self, params, signal, data, error, *args, **kwargs):
685 """Calculate negative log likelihood of the model.
687 Parameters
688 ----------
689 params : `lmfit.Parameters`
690 Object containing the model parameters.
691 signal : `np.ndarray`, (nMeasurements)
692 Array of image means.
693 data : `np.ndarray`, (nMeasurements, nCols)
694 Array of overscan column means from each measurement.
695 error : `float`
696 Fixed error value.
697 *args :
698 Additional position arguments.
699 **kwargs :
700 Additional keyword arguments.
702 Returns
703 -------
704 negativelogL : `float`
705 The negative log-likelihood of the observed data given the
706 model parameters.
707 """
708 ll = self.loglikelihood(params, signal, data, error, *args, **kwargs)
710 return -ll
712 def rms_error(self, params, signal, data, error, *args, **kwargs):
713 """Calculate RMS error 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 rms : `float`
733 The rms error between the model and input data.
734 """
735 model_results = self.model_results(params, signal, *args, **kwargs)
737 diff = model_results - data
738 rms = np.sqrt(np.mean(np.square(diff)))
740 return rms
742 def difference(self, params, signal, data, error, *args, **kwargs):
743 """Calculate the flattened difference array between model and data.
745 Parameters
746 ----------
747 params : `lmfit.Parameters`
748 Object containing the model parameters.
749 signal : `np.ndarray`, (nMeasurements)
750 Array of image means.
751 data : `np.ndarray`, (nMeasurements, nCols)
752 Array of overscan column means from each measurement.
753 error : `float`
754 Fixed error value.
755 *args :
756 Additional position arguments.
757 **kwargs :
758 Additional keyword arguments.
760 Returns
761 -------
762 difference : `np.ndarray`, (nMeasurements*nCols)
763 The rms error between the model and input data.
764 """
765 model_results = self.model_results(params, signal, *args, **kwargs)
766 diff = (model_results-data).flatten()
768 return diff
771class SimpleModel(OverscanModel):
772 """Simple analytic overscan model."""
774 @staticmethod
775 def model_results(params, signal, num_transfers, start=1, stop=10):
776 """Generate a realization of the overscan model, using the specified
777 fit parameters and input signal.
779 Parameters
780 ----------
781 params : `lmfit.Parameters`
782 Object containing the model parameters.
783 signal : `np.ndarray`, (nMeasurements)
784 Array of image means.
785 num_transfers : `int`
786 Number of serial transfers that the charge undergoes.
787 start : `int`, optional
788 First overscan column to fit. This number includes the
789 last imaging column, and needs to be adjusted by one when
790 using the overscan bounding box.
791 stop : `int`, optional
792 Last overscan column to fit. This number includes the
793 last imaging column, and needs to be adjusted by one when
794 using the overscan bounding box.
796 Returns
797 -------
798 res : `np.ndarray`, (nMeasurements, nCols)
799 Model results.
800 """
801 v = params.valuesdict()
802 v['cti'] = 10**v['ctiexp']
804 # Adjust column numbering to match DM overscan bbox.
805 start += 1
806 stop += 1
808 x = np.arange(start, stop+1)
809 res = np.zeros((signal.shape[0], x.shape[0]))
811 for i, s in enumerate(signal):
812 # This is largely equivalent to equation 2. The minimum
813 # indicates that a trap cannot emit more charge than is
814 # available, nor can it emit more charge than it can hold.
815 # This scales the exponential release of charge from the
816 # trap. The next term defines the contribution from the
817 # global CTI at each pixel transfer, and the final term
818 # includes the contribution from local CTI effects.
819 res[i, :] = (np.minimum(v['trapsize'], s*v['scaling'])
820 * (np.exp(1/v['emissiontime']) - 1.0)
821 * np.exp(-x/v['emissiontime'])
822 + s*num_transfers*v['cti']**x
823 + v['driftscale']*s*np.exp(-x/float(v['decaytime'])))
825 return res
828class SimulatedModel(OverscanModel):
829 """Simulated overscan model."""
831 @staticmethod
832 def model_results(params, signal, num_transfers, amp, start=1, stop=10, trap_type=None):
833 """Generate a realization of the overscan model, using the specified
834 fit parameters and input signal.
836 Parameters
837 ----------
838 params : `lmfit.Parameters`
839 Object containing the model parameters.
840 signal : `np.ndarray`, (nMeasurements)
841 Array of image means.
842 num_transfers : `int`
843 Number of serial transfers that the charge undergoes.
844 amp : `lsst.afw.cameraGeom.Amplifier`
845 Amplifier to use for geometry information.
846 start : `int`, optional
847 First overscan column to fit. This number includes the
848 last imaging column, and needs to be adjusted by one when
849 using the overscan bounding box.
850 stop : `int`, optional
851 Last overscan column to fit. This number includes the
852 last imaging column, and needs to be adjusted by one when
853 using the overscan bounding box.
854 trap_type : `str`, optional
855 Type of trap model to use.
857 Returns
858 -------
859 results : `np.ndarray`, (nMeasurements, nCols)
860 Model results.
861 """
862 v = params.valuesdict()
864 # Adjust column numbering to match DM overscan bbox.
865 start += 1
866 stop += 1
868 # Electronics effect optimization
869 output_amplifier = FloatingOutputAmplifier(1.0, v['driftscale'], v['decaytime'])
871 # CTI optimization
872 v['cti'] = 10**v['ctiexp']
874 # Trap type for optimization
875 if trap_type is None:
876 trap = None
877 elif trap_type == 'linear':
878 trap = SerialTrap(v['trapsize'], v['emissiontime'], 1, 'linear',
879 [v['scaling']])
880 elif trap_type == 'logistic':
881 trap = SerialTrap(v['trapsize'], v['emissiontime'], 1, 'logistic',
882 [v['f0'], v['k']])
883 else:
884 raise ValueError('Trap type must be linear or logistic or None')
886 # Simulate ramp readout
887 imarr = np.zeros((signal.shape[0], amp.getRawDataBBox().getWidth()))
888 ramp = SegmentSimulator(imarr, amp.getRawSerialPrescanBBox().getWidth(), output_amplifier,
889 cti=v['cti'], traps=trap)
890 ramp.ramp_exp(signal)
891 model_results = ramp.readout(serial_overscan_width=amp.getRawSerialOverscanBBox().getWidth(),
892 parallel_overscan_width=0)
894 ncols = amp.getRawSerialPrescanBBox().getWidth() + amp.getRawDataBBox().getWidth()
896 return model_results[:, ncols+start-1:ncols+stop]
899class SegmentSimulator:
900 """Controls the creation of simulated segment images.
902 Parameters
903 ----------
904 imarr : `np.ndarray` (nx, ny)
905 Image data array.
906 prescan_width : `int`
907 Number of serial prescan columns.
908 output_amplifier : `lsst.cp.pipe.FloatingOutputAmplifier`
909 An object holding the gain, read noise, and global_offset.
910 cti : `float`
911 Global CTI value.
912 traps : `list` [`lsst.ip.isr.SerialTrap`]
913 Serial traps to simulate.
914 """
916 def __init__(self, imarr, prescan_width, output_amplifier, cti=0.0, traps=None):
917 # Image array geometry
918 self.prescan_width = prescan_width
919 self.ny, self.nx = imarr.shape
921 self.segarr = np.zeros((self.ny, self.nx+prescan_width))
922 self.segarr[:, prescan_width:] = imarr
924 # Serial readout information
925 self.output_amplifier = output_amplifier
926 if isinstance(cti, np.ndarray):
927 raise ValueError("cti must be single value, not an array.")
928 self.cti = cti
930 self.serial_traps = None
931 self.do_trapping = False
932 if traps is not None:
933 if not isinstance(traps, list):
934 traps = [traps]
935 for trap in traps:
936 self.add_trap(trap)
938 def add_trap(self, serial_trap):
939 """Add a trap to the serial register.
941 Parameters
942 ----------
943 serial_trap : `lsst.ip.isr.SerialTrap`
944 The trap to add.
945 """
946 try:
947 self.serial_traps.append(serial_trap)
948 except AttributeError:
949 self.serial_traps = [serial_trap]
950 self.do_trapping = True
952 def ramp_exp(self, signal_list):
953 """Simulate an image with varying flux illumination per row.
955 This method simulates a segment image where the signal level
956 increases along the horizontal direction, according to the
957 provided list of signal levels.
959 Parameters
960 ----------
961 signal_list : `list` [`float`]
962 List of signal levels.
964 Raises
965 ------
966 ValueError
967 Raised if the length of the signal list does not equal the
968 number of rows.
969 """
970 if len(signal_list) != self.ny:
971 raise ValueError("Signal list does not match row count.")
973 ramp = np.tile(signal_list, (self.nx, 1)).T
974 self.segarr[:, self.prescan_width:] += ramp
976 def readout(self, serial_overscan_width=10, parallel_overscan_width=0):
977 """Simulate serial readout of the segment image.
979 This method performs the serial readout of a segment image
980 given the appropriate SerialRegister object and the properties
981 of the ReadoutAmplifier. Additional arguments can be provided
982 to account for the number of desired overscan transfers. The
983 result is a simulated final segment image, in ADU.
985 Parameters
986 ----------
987 serial_overscan_width : `int`, optional
988 Number of serial overscan columns.
989 parallel_overscan_width : `int`, optional
990 Number of parallel overscan rows.
992 Returns
993 -------
994 result : `np.ndarray` (nx, ny)
995 Simulated image, including serial prescan, serial
996 overscan, and parallel overscan regions.
997 """
998 # Create output array
999 iy = int(self.ny + parallel_overscan_width)
1000 ix = int(self.nx + self.prescan_width + serial_overscan_width)
1001 image = np.random.normal(loc=self.output_amplifier.global_offset,
1002 scale=self.output_amplifier.noise,
1003 size=(iy, ix))
1004 free_charge = copy.deepcopy(self.segarr)
1006 # Set flow control parameters
1007 do_trapping = self.do_trapping
1008 cti = self.cti
1010 offset = np.zeros(self.ny)
1011 cte = 1 - cti
1012 if do_trapping:
1013 for trap in self.serial_traps:
1014 trap.initialize(self.ny, self.nx, self.prescan_width)
1016 for i in range(ix):
1017 # Trap capture
1018 if do_trapping:
1019 for trap in self.serial_traps:
1020 captured_charge = trap.trap_charge(free_charge)
1021 free_charge -= captured_charge
1023 # Pixel-to-pixel proportional loss
1024 transferred_charge = free_charge*cte
1025 deferred_charge = free_charge*cti
1027 # Pixel transfer and readout
1028 offset = self.output_amplifier.local_offset(offset,
1029 transferred_charge[:, 0])
1030 image[:iy-parallel_overscan_width, i] += transferred_charge[:, 0] + offset
1032 free_charge = np.pad(transferred_charge, ((0, 0), (0, 1)),
1033 mode='constant')[:, 1:] + deferred_charge
1035 # Trap emission
1036 if do_trapping:
1037 for trap in self.serial_traps:
1038 released_charge = trap.release_charge()
1039 free_charge += released_charge
1041 return image/float(self.output_amplifier.gain)
1044class FloatingOutputAmplifier:
1045 """Object representing the readout amplifier of a single channel.
1047 Parameters
1048 ----------
1049 gain : `float`
1050 Amplifier gain.
1051 scale : `float`
1052 Drift scale for the amplifier.
1053 decay_time : `float`
1054 Decay time for the bias drift.
1055 noise : `float`, optional
1056 Amplifier read noise.
1057 offset : `float`, optional
1058 Global CTI offset.
1059 """
1061 def __init__(self, gain, scale, decay_time, noise=0.0, offset=0.0):
1063 self.gain = gain
1064 self.noise = noise
1065 self.global_offset = offset
1067 self.update_parameters(scale, decay_time)
1069 def local_offset(self, old, signal):
1070 """Calculate local offset hysteresis.
1072 Parameters
1073 ----------
1074 old : `np.ndarray`, (,)
1075 Previous iteration.
1076 signal : `np.ndarray`, (,)
1077 Current column measurements.
1079 Returns
1080 -------
1081 offset : `np.ndarray`
1082 Local offset.
1083 """
1084 new = self.scale*signal
1086 return np.maximum(new, old*np.exp(-1/self.decay_time))
1088 def update_parameters(self, scale, decay_time):
1089 """Update parameter values, if within acceptable values.
1091 Parameters
1092 ----------
1093 scale : `float`
1094 Drift scale for the amplifier.
1095 decay_time : `float`
1096 Decay time for the bias drift.
1098 Raises
1099 ------
1100 ValueError
1101 Raised if the input parameters are out of range.
1102 """
1103 if scale < 0.0:
1104 raise ValueError("Scale must be greater than or equal to 0.")
1105 if np.isnan(scale):
1106 raise ValueError("Scale must be real-valued number, not NaN.")
1107 self.scale = scale
1108 if decay_time <= 0.0:
1109 raise ValueError("Decay time must be greater than 0.")
1110 if np.isnan(decay_time):
1111 raise ValueError("Decay time must be real-valued number, not NaN.")
1112 self.decay_time = decay_time