LSST Applications g0f08755f38+82efc23009,g12f32b3c4e+e7bdf1200e,g1653933729+a8ce1bb630,g1a0ca8cf93+50eff2b06f,g28da252d5a+52db39f6a5,g2bbee38e9b+37c5a29d61,g2bc492864f+37c5a29d61,g2cdde0e794+c05ff076ad,g3156d2b45e+41e33cbcdc,g347aa1857d+37c5a29d61,g35bb328faa+a8ce1bb630,g3a166c0a6a+37c5a29d61,g3e281a1b8c+fb992f5633,g414038480c+7f03dfc1b0,g41af890bb2+11b950c980,g5fbc88fb19+17cd334064,g6b1c1869cb+12dd639c9a,g781aacb6e4+a8ce1bb630,g80478fca09+72e9651da0,g82479be7b0+04c31367b4,g858d7b2824+82efc23009,g9125e01d80+a8ce1bb630,g9726552aa6+8047e3811d,ga5288a1d22+e532dc0a0b,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gc28159a63d+37c5a29d61,gcf0d15dbbd+2acd6d4d48,gd7358e8bfb+778a810b6e,gda3e153d99+82efc23009,gda6a2b7d83+2acd6d4d48,gdaeeff99f8+1711a396fd,ge2409df99d+6b12de1076,ge79ae78c31+37c5a29d61,gf0baf85859+d0a5978c5a,gf3967379c6+4954f8c433,gfb92a5be7c+82efc23009,gfec2e1e490+2aaed99252,w.2024.46
LSST Data Management Base Package
Loading...
Searching...
No Matches
deferredCharge.py
Go to the documentation of this file.
1# This file is part of ip_isr.
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__ = ('DeferredChargeConfig',
23 'DeferredChargeTask',
24 'SerialTrap',
25 'OverscanModel',
26 'SimpleModel',
27 'SimulatedModel',
28 'SegmentSimulator',
29 'FloatingOutputAmplifier',
30 'DeferredChargeCalib',
31 )
32
33import copy
34import numpy as np
35import warnings
36from astropy.table import Table
37
38from lsst.afw.cameraGeom import ReadoutCorner
39from lsst.pex.config import Config, Field
40from lsst.pipe.base import Task
41from .isrFunctions import gainContext
42from .calibType import IsrCalib
43
44import scipy.interpolate as interp
45
46
47class SerialTrap():
48 """Represents a serial register trap.
49
50 Parameters
51 ----------
52 size : `float`
53 Size of the charge trap, in electrons.
54 emission_time : `float`
55 Trap emission time constant, in inverse transfers.
56 pixel : `int`
57 Serial pixel location of the trap, including the prescan.
58 trap_type : `str`
59 Type of trap capture to use. Should be one of ``linear``,
60 ``logistic``, or ``spline``.
61 coeffs : `list` [`float`]
62 Coefficients for the capture process. Linear traps need one
63 coefficient, logistic traps need two, and spline based traps
64 need to have an even number of coefficients that can be split
65 into their spline locations and values.
66
67 Raises
68 ------
69 ValueError
70 Raised if the specified parameters are out of expected range.
71 """
72
73 def __init__(self, size, emission_time, pixel, trap_type, coeffs):
74 if size < 0.0:
75 raise ValueError('Trap size must be greater than or equal to 0.')
76 self.size = size
77
78 if emission_time <= 0.0:
79 raise ValueError('Emission time must be greater than 0.')
80 if np.isnan(emission_time):
81 raise ValueError('Emission time must be real-valued, not NaN')
82 self.emission_time = emission_time
83
84 if int(pixel) != pixel:
85 raise ValueError('Fraction value for pixel not allowed.')
86 self.pixel = int(pixel)
87
88 self.trap_type = trap_type
89 self.coeffs = coeffs
90
91 if self.trap_type not in ('linear', 'logistic', 'spline'):
92 raise ValueError('Unknown trap type: %s', self.trap_type)
93
94 if self.trap_type == 'spline':
95 # Note that ``spline`` is actually a piecewise linear interpolation
96 # in the model and the application, and not a true spline.
97 centers, values = np.split(np.array(self.coeffs, dtype=np.float64), 2)
98 # Ensure all NaN values are stripped out
99 values = values[~np.isnan(centers)]
100 centers = centers[~np.isnan(centers)]
101 centers = centers[~np.isnan(values)]
102 values = values[~np.isnan(values)]
103 self.interp = interp.interp1d(
104 centers,
105 values,
106 bounds_error=False,
107 fill_value=(values[0], values[-1]),
108 )
109
110 self._trap_array = None
111 self._trapped_charge = None
112
113 def __eq__(self, other):
114 # A trap is equal to another trap if all of the initialization
115 # parameters are equal. All other properties are only filled
116 # during use, and are not persisted into the calibration.
117 if self.size != other.size:
118 return False
119 if self.emission_time != other.emission_time:
120 return False
121 if self.pixel != other.pixel:
122 return False
123 if self.trap_type != other.trap_type:
124 return False
125 if self.coeffs != other.coeffs:
126 return False
127 return True
128
129 @property
130 def trap_array(self):
131 return self._trap_array
132
133 @property
134 def trapped_charge(self):
135 return self._trapped_charge
136
137 def initialize(self, ny, nx, prescan_width):
138 """Initialize trapping arrays for simulated readout.
139
140 Parameters
141 ----------
142 ny : `int`
143 Number of rows to simulate.
144 nx : `int`
145 Number of columns to simulate.
146 prescan_width : `int`
147 Additional transfers due to prescan.
148
149 Raises
150 ------
151 ValueError
152 Raised if the trap falls outside of the image.
153 """
154 if self.pixel > nx+prescan_width:
155 raise ValueError('Trap location {0} must be less than {1}'.format(self.pixel,
156 nx+prescan_width))
157
158 self._trap_array = np.zeros((ny, nx+prescan_width))
159 self._trap_array[:, self.pixel] = self.size
160 self._trapped_charge = np.zeros((ny, nx+prescan_width))
161
162 def release_charge(self):
163 """Release charge through exponential decay.
164
165 Returns
166 -------
167 released_charge : `float`
168 Charge released.
169 """
170 released_charge = self._trapped_charge*(1-np.exp(-1./self.emission_time))
171 self._trapped_charge -= released_charge
172
173 return released_charge
174
175 def trap_charge(self, free_charge):
176 """Perform charge capture using a logistic function.
177
178 Parameters
179 ----------
180 free_charge : `float`
181 Charge available to be trapped.
182
183 Returns
184 -------
185 captured_charge : `float`
186 Amount of charge actually trapped.
187 """
188 captured_charge = (np.clip(self.capture(free_charge), self.trapped_chargetrapped_charge, self._trap_array)
190 self._trapped_charge += captured_charge
191
192 return captured_charge
193
194 def capture(self, pixel_signals):
195 """Trap capture function.
196
197 Parameters
198 ----------
199 pixel_signals : `list` [`float`]
200 Input pixel values.
201
202 Returns
203 -------
204 captured_charge : `list` [`float`]
205 Amount of charge captured from each pixel.
206
207 Raises
208 ------
209 RuntimeError
210 Raised if the trap type is invalid.
211 """
212 if self.trap_type == 'linear':
213 scaling = self.coeffs[0]
214 return np.minimum(self.size, pixel_signals*scaling)
215 elif self.trap_type == 'logistic':
216 f0, k = (self.coeffs[0], self.coeffs[1])
217 return self.size/(1.+np.exp(-k*(pixel_signals-f0)))
218 elif self.trap_type == 'spline':
219 return self.interp(pixel_signals)
220 else:
221 raise RuntimeError(f"Invalid trap capture type: {self.trap_type}.")
222
223
225 """Base class for handling model/data fit comparisons.
226 This handles all of the methods needed for the lmfit Minimizer to
227 run.
228 """
229
230 @staticmethod
231 def model_results(params, signal, num_transfers, start=1, stop=10):
232 """Generate a realization of the overscan model, using the specified
233 fit parameters and input signal.
234
235 Parameters
236 ----------
237 params : `lmfit.Parameters`
238 Object containing the model parameters.
239 signal : `np.ndarray`, (nMeasurements)
240 Array of image means.
241 num_transfers : `int`
242 Number of serial transfers that the charge undergoes.
243 start : `int`, optional
244 First overscan column to fit. This number includes the
245 last imaging column, and needs to be adjusted by one when
246 using the overscan bounding box.
247 stop : `int`, optional
248 Last overscan column to fit. This number includes the
249 last imaging column, and needs to be adjusted by one when
250 using the overscan bounding box.
251
252 Returns
253 -------
254 results : `np.ndarray`, (nMeasurements, nCols)
255 Model results.
256 """
257 raise NotImplementedError("Subclasses must implement the model calculation.")
258
259 def loglikelihood(self, params, signal, data, error, *args, **kwargs):
260 """Calculate log likelihood of the model.
261
262 Parameters
263 ----------
264 params : `lmfit.Parameters`
265 Object containing the model parameters.
266 signal : `np.ndarray`, (nMeasurements)
267 Array of image means.
268 data : `np.ndarray`, (nMeasurements, nCols)
269 Array of overscan column means from each measurement.
270 error : `float`
271 Fixed error value.
272 *args :
273 Additional position arguments.
274 **kwargs :
275 Additional keyword arguments.
276
277 Returns
278 -------
279 logL : `float`
280 The log-likelihood of the observed data given the model
281 parameters.
282 """
283 model_results = self.model_results(params, signal, *args, **kwargs)
284
285 inv_sigma2 = 1.0/(error**2.0)
286 diff = model_results - data
287
288 return -0.5*(np.sum(inv_sigma2*(diff)**2.))
289
290 def negative_loglikelihood(self, params, signal, data, error, *args, **kwargs):
291 """Calculate negative log likelihood of the model.
292
293 Parameters
294 ----------
295 params : `lmfit.Parameters`
296 Object containing the model parameters.
297 signal : `np.ndarray`, (nMeasurements)
298 Array of image means.
299 data : `np.ndarray`, (nMeasurements, nCols)
300 Array of overscan column means from each measurement.
301 error : `float`
302 Fixed error value.
303 *args :
304 Additional position arguments.
305 **kwargs :
306 Additional keyword arguments.
307
308 Returns
309 -------
310 negativelogL : `float`
311 The negative log-likelihood of the observed data given the
312 model parameters.
313 """
314 ll = self.loglikelihood(params, signal, data, error, *args, **kwargs)
315
316 return -ll
317
318 def rms_error(self, params, signal, data, error, *args, **kwargs):
319 """Calculate RMS error between model and data.
320
321 Parameters
322 ----------
323 params : `lmfit.Parameters`
324 Object containing the model parameters.
325 signal : `np.ndarray`, (nMeasurements)
326 Array of image means.
327 data : `np.ndarray`, (nMeasurements, nCols)
328 Array of overscan column means from each measurement.
329 error : `float`
330 Fixed error value.
331 *args :
332 Additional position arguments.
333 **kwargs :
334 Additional keyword arguments.
335
336 Returns
337 -------
338 rms : `float`
339 The rms error between the model and input data.
340 """
341 model_results = self.model_results(params, signal, *args, **kwargs)
342
343 diff = model_results - data
344 rms = np.sqrt(np.mean(np.square(diff)))
345
346 return rms
347
348 def difference(self, params, signal, data, error, *args, **kwargs):
349 """Calculate the flattened difference array between model and data.
350
351 Parameters
352 ----------
353 params : `lmfit.Parameters`
354 Object containing the model parameters.
355 signal : `np.ndarray`, (nMeasurements)
356 Array of image means.
357 data : `np.ndarray`, (nMeasurements, nCols)
358 Array of overscan column means from each measurement.
359 error : `float`
360 Fixed error value.
361 *args :
362 Additional position arguments.
363 **kwargs :
364 Additional keyword arguments.
365
366 Returns
367 -------
368 difference : `np.ndarray`, (nMeasurements*nCols)
369 The rms error between the model and input data.
370 """
371 model_results = self.model_results(params, signal, *args, **kwargs)
372 diff = (model_results-data).flatten()
373
374 return diff
375
376
378 """Simple analytic overscan model."""
379
380 @staticmethod
381 def model_results(params, signal, num_transfers, start=1, stop=10):
382 """Generate a realization of the overscan model, using the specified
383 fit parameters and input signal.
384
385 Parameters
386 ----------
387 params : `lmfit.Parameters`
388 Object containing the model parameters.
389 signal : `np.ndarray`, (nMeasurements)
390 Array of image means.
391 num_transfers : `int`
392 Number of serial transfers that the charge undergoes.
393 start : `int`, optional
394 First overscan column to fit. This number includes the
395 last imaging column, and needs to be adjusted by one when
396 using the overscan bounding box.
397 stop : `int`, optional
398 Last overscan column to fit. This number includes the
399 last imaging column, and needs to be adjusted by one when
400 using the overscan bounding box.
401
402 Returns
403 -------
404 res : `np.ndarray`, (nMeasurements, nCols)
405 Model results.
406 """
407 v = params.valuesdict()
408 v['cti'] = 10**v['ctiexp']
409
410 # Adjust column numbering to match DM overscan bbox.
411 start += 1
412 stop += 1
413
414 x = np.arange(start, stop+1)
415 res = np.zeros((signal.shape[0], x.shape[0]))
416
417 for i, s in enumerate(signal):
418 # This is largely equivalent to equation 2. The minimum
419 # indicates that a trap cannot emit more charge than is
420 # available, nor can it emit more charge than it can hold.
421 # This scales the exponential release of charge from the
422 # trap. The next term defines the contribution from the
423 # global CTI at each pixel transfer, and the final term
424 # includes the contribution from local CTI effects.
425 res[i, :] = (np.minimum(v['trapsize'], s*v['scaling'])
426 * (np.exp(1/v['emissiontime']) - 1.0)
427 * np.exp(-x/v['emissiontime'])
428 + s*num_transfers*v['cti']**x
429 + v['driftscale']*s*np.exp(-x/float(v['decaytime'])))
430
431 return res
432
433
435 """Simulated overscan model."""
436
437 @staticmethod
438 def model_results(params, signal, num_transfers, amp, start=1, stop=10, trap_type=None):
439 """Generate a realization of the overscan model, using the specified
440 fit parameters and input signal.
441
442 Parameters
443 ----------
444 params : `lmfit.Parameters`
445 Object containing the model parameters.
446 signal : `np.ndarray`, (nMeasurements)
447 Array of image means.
448 num_transfers : `int`
449 Number of serial transfers that the charge undergoes.
450 amp : `lsst.afw.cameraGeom.Amplifier`
451 Amplifier to use for geometry information.
452 start : `int`, optional
453 First overscan column to fit. This number includes the
454 last imaging column, and needs to be adjusted by one when
455 using the overscan bounding box.
456 stop : `int`, optional
457 Last overscan column to fit. This number includes the
458 last imaging column, and needs to be adjusted by one when
459 using the overscan bounding box.
460 trap_type : `str`, optional
461 Type of trap model to use.
462
463 Returns
464 -------
465 results : `np.ndarray`, (nMeasurements, nCols)
466 Model results.
467 """
468 v = params.valuesdict()
469
470 # Adjust column numbering to match DM overscan bbox.
471 start += 1
472 stop += 1
473
474 # Electronics effect optimization
475 output_amplifier = FloatingOutputAmplifier(1.0, v['driftscale'], v['decaytime'])
476
477 # CTI optimization
478 v['cti'] = 10**v['ctiexp']
479
480 # Trap type for optimization
481 if trap_type is None:
482 trap = None
483 elif trap_type == 'linear':
484 trap = SerialTrap(v['trapsize'], v['emissiontime'], 1, 'linear',
485 [v['scaling']])
486 elif trap_type == 'logistic':
487 trap = SerialTrap(v['trapsize'], v['emissiontime'], 1, 'logistic',
488 [v['f0'], v['k']])
489 else:
490 raise ValueError('Trap type must be linear or logistic or None')
491
492 # Simulate ramp readout
493 imarr = np.zeros((signal.shape[0], amp.getRawDataBBox().getWidth()))
494 ramp = SegmentSimulator(imarr, amp.getRawSerialPrescanBBox().getWidth(), output_amplifier,
495 cti=v['cti'], traps=trap)
496 ramp.ramp_exp(signal)
497 model_results = ramp.readout(serial_overscan_width=amp.getRawSerialOverscanBBox().getWidth(),
498 parallel_overscan_width=0)
499
500 ncols = amp.getRawSerialPrescanBBox().getWidth() + amp.getRawDataBBox().getWidth()
501
502 return model_results[:, ncols+start-1:ncols+stop]
503
504
506 """Controls the creation of simulated segment images.
507
508 Parameters
509 ----------
510 imarr : `np.ndarray` (nx, ny)
511 Image data array.
512 prescan_width : `int`
513 Number of serial prescan columns.
514 output_amplifier : `lsst.cp.pipe.FloatingOutputAmplifier`
515 An object holding some deferred charge parameters.
516 cti : `float`
517 Global CTI value.
518 traps : `list` [`lsst.ip.isr.SerialTrap`]
519 Serial traps to simulate.
520 """
521
522 def __init__(self, imarr, prescan_width, output_amplifier, cti=0.0, traps=None):
523 # Image array geometry
524 self.prescan_width = prescan_width
525 self.ny, self.nx = imarr.shape
526
527 self.segarr = np.zeros((self.ny, self.nx+prescan_width))
528 self.segarr[:, prescan_width:] = imarr
529
530 # Serial readout information
531 self.output_amplifier = output_amplifier
532 if isinstance(cti, np.ndarray):
533 raise ValueError("cti must be single value, not an array.")
534 self.cti = cti
535
536 self.serial_traps = None
537 self.do_trapping = False
538 if traps is not None:
539 if not isinstance(traps, list):
540 traps = [traps]
541 for trap in traps:
542 self.add_trap(trap)
543
544 def add_trap(self, serial_trap):
545 """Add a trap to the serial register.
546
547 Parameters
548 ----------
549 serial_trap : `lsst.ip.isr.SerialTrap`
550 The trap to add.
551 """
552 try:
553 self.serial_traps.append(serial_trap)
554 except AttributeError:
555 self.serial_traps = [serial_trap]
556 self.do_trapping = True
557
558 def ramp_exp(self, signal_list):
559 """Simulate an image with varying flux illumination per row.
560
561 This method simulates a segment image where the signal level
562 increases along the horizontal direction, according to the
563 provided list of signal levels.
564
565 Parameters
566 ----------
567 signal_list : `list` [`float`]
568 List of signal levels.
569
570 Raises
571 ------
572 ValueError
573 Raised if the length of the signal list does not equal the
574 number of rows.
575 """
576 if len(signal_list) != self.ny:
577 raise ValueError("Signal list does not match row count.")
578
579 ramp = np.tile(signal_list, (self.nx, 1)).T
580 self.segarr[:, self.prescan_width:] += ramp
581
582 def readout(self, serial_overscan_width=10, parallel_overscan_width=0):
583 """Simulate serial readout of the segment image.
584
585 This method performs the serial readout of a segment image
586 given the appropriate SerialRegister object and the properties
587 of the ReadoutAmplifier. Additional arguments can be provided
588 to account for the number of desired overscan transfers. The
589 result is a simulated final segment image, in ADU.
590
591 Parameters
592 ----------
593 serial_overscan_width : `int`, optional
594 Number of serial overscan columns.
595 parallel_overscan_width : `int`, optional
596 Number of parallel overscan rows.
597
598 Returns
599 -------
600 result : `np.ndarray` (nx, ny)
601 Simulated image, including serial prescan, serial
602 overscan, and parallel overscan regions. Result in electrons.
603 """
604 # Create output array
605 iy = int(self.ny + parallel_overscan_width)
606 ix = int(self.nx + self.prescan_width + serial_overscan_width)
607
608 image = np.random.normal(
609 loc=self.output_amplifier.global_offset,
610 scale=self.output_amplifier.noise,
611 size=(iy, ix),
612 )
613
614 free_charge = copy.deepcopy(self.segarr)
615
616 # Set flow control parameters
617 do_trapping = self.do_trapping
618 cti = self.cti
619
620 offset = np.zeros(self.ny)
621 cte = 1 - cti
622 if do_trapping:
623 for trap in self.serial_traps:
624 trap.initialize(self.ny, self.nx, self.prescan_width)
625
626 for i in range(ix):
627 # Trap capture
628 if do_trapping:
629 for trap in self.serial_traps:
630 captured_charge = trap.trap_charge(free_charge)
631 free_charge -= captured_charge
632
633 # Pixel-to-pixel proportional loss
634 transferred_charge = free_charge*cte
635 deferred_charge = free_charge*cti
636
637 # Pixel transfer and readout
638 offset = self.output_amplifier.local_offset(offset,
639 transferred_charge[:, 0])
640 image[:iy-parallel_overscan_width, i] += transferred_charge[:, 0] + offset
641
642 free_charge = np.pad(transferred_charge, ((0, 0), (0, 1)),
643 mode='constant')[:, 1:] + deferred_charge
644
645 # Trap emission
646 if do_trapping:
647 for trap in self.serial_traps:
648 released_charge = trap.release_charge()
649 free_charge += released_charge
650
651 return image
652
653
655 """Object representing the readout amplifier of a single channel.
656
657 Parameters
658 ----------
659 gain : `float`
660 Gain of the amplifier. Currently not used.
661 scale : `float`
662 Drift scale for the amplifier.
663 decay_time : `float`
664 Decay time for the bias drift.
665 noise : `float`, optional
666 Amplifier read noise.
667 offset : `float`, optional
668 Global CTI offset.
669 """
670
671 def __init__(self, gain, scale, decay_time, noise=0.0, offset=0.0):
672
673 self.gain = gain
674 self.noise = noise
675 self.global_offset = offset
676
677 self.update_parameters(scale, decay_time)
678
679 def local_offset(self, old, signal):
680 """Calculate local offset hysteresis.
681
682 Parameters
683 ----------
684 old : `np.ndarray`, (,)
685 Previous iteration.
686 signal : `np.ndarray`, (,)
687 Current column measurements.
688 Returns
689 -------
690 offset : `np.ndarray`
691 Local offset.
692 """
693 new = self.scale*signal
694
695 return np.maximum(new, old*np.exp(-1/self.decay_time))
696
697 def update_parameters(self, scale, decay_time):
698 """Update parameter values, if within acceptable values.
699
700 Parameters
701 ----------
702 scale : `float`
703 Drift scale for the amplifier.
704 decay_time : `float`
705 Decay time for the bias drift.
706
707 Raises
708 ------
709 ValueError
710 Raised if the input parameters are out of range.
711 """
712 if scale < 0.0:
713 raise ValueError("Scale must be greater than or equal to 0.")
714 if np.isnan(scale):
715 raise ValueError("Scale must be real-valued number, not NaN.")
716 self.scale = scale
717 if decay_time <= 0.0:
718 raise ValueError("Decay time must be greater than 0.")
719 if np.isnan(decay_time):
720 raise ValueError("Decay time must be real-valued number, not NaN.")
721 self.decay_time = decay_time
722
723
725 r"""Calibration containing deferred charge/CTI parameters.
726
727 Parameters
728 ----------
729 **kwargs :
730 Additional parameters to pass to parent constructor.
731
732 Notes
733 -----
734 The charge transfer inefficiency attributes stored are:
735
736 driftScale : `dict` [`str`, `float`]
737 A dictionary, keyed by amplifier name, of the local electronic
738 offset drift scale parameter, A_L in Snyder+2021.
739 decayTime : `dict` [`str`, `float`]
740 A dictionary, keyed by amplifier name, of the local electronic
741 offset decay time, \tau_L in Snyder+2021.
742 globalCti : `dict` [`str`, `float`]
743 A dictionary, keyed by amplifier name, of the mean global CTI
744 paramter, b in Snyder+2021.
745 serialTraps : `dict` [`str`, `lsst.ip.isr.SerialTrap`]
746 A dictionary, keyed by amplifier name, containing a single
747 serial trap for each amplifier.
748
749 Also, the values contained in this calibration are all derived
750 from and image and overscan in units of electron as these are
751 the most natural units in which to compute deferred charge.
752 However, this means the the user should supply a reliable set
753 of gains when computing the CTI statistics during ISR.
754
755 Version 1.1 deprecates the USEGAINS attribute and standardizes
756 everything to electron units.
757 """
758 _OBSTYPE = 'CTI'
759 _SCHEMA = 'Deferred Charge'
760 _VERSION = 1.1
761
762 def __init__(self, **kwargs):
763 self.driftScale = {}
764 self.decayTime = {}
765 self.globalCti = {}
766 self.serialTraps = {}
767
768 # Check for deprecated kwargs
769 if kwargs.pop("useGains", None) is not None:
770 warnings.warn("useGains is deprecated, and will be removed "
771 "after v28.", FutureWarning)
772
773 super().__init__(**kwargs)
774
775 # Units are always in electron.
776 self.updateMetadata(UNITS='electron')
777
778 self.requiredAttributesrequiredAttributesrequiredAttributes.update(['driftScale', 'decayTime', 'globalCti', 'serialTraps'])
779
780 def fromDetector(self, detector):
781 """Read metadata parameters from a detector.
782
783 Parameters
784 ----------
785 detector : `lsst.afw.cameraGeom.detector`
786 Input detector with parameters to use.
787
788 Returns
789 -------
790 calib : `lsst.ip.isr.Linearizer`
791 The calibration constructed from the detector.
792 """
793
794 pass
795
796 @classmethod
797 def fromDict(cls, dictionary):
798 """Construct a calibration from a dictionary of properties.
799
800 Parameters
801 ----------
802 dictionary : `dict`
803 Dictionary of properties.
804
805 Returns
806 -------
807 calib : `lsst.ip.isr.CalibType`
808 Constructed calibration.
809
810 Raises
811 ------
812 RuntimeError
813 Raised if the supplied dictionary is for a different
814 calibration.
815 """
816 calib = cls()
817
818 if calib._OBSTYPE != dictionary['metadata']['OBSTYPE']:
819 raise RuntimeError(f"Incorrect CTI supplied. Expected {calib._OBSTYPE}, "
820 f"found {dictionary['metadata']['OBSTYPE']}")
821
822 calib.setMetadata(dictionary['metadata'])
823
824 calib.driftScale = dictionary['driftScale']
825 calib.decayTime = dictionary['decayTime']
826 calib.globalCti = dictionary['globalCti']
827
828 for ampName in dictionary['serialTraps']:
829 ampTraps = dictionary['serialTraps'][ampName]
830 calib.serialTraps[ampName] = SerialTrap(ampTraps['size'], ampTraps['emissionTime'],
831 ampTraps['pixel'], ampTraps['trap_type'],
832 ampTraps['coeffs'])
833 calib.updateMetadata()
834 return calib
835
836 def toDict(self):
837 """Return a dictionary containing the calibration properties.
838 The dictionary should be able to be round-tripped through
839 ``fromDict``.
840
841 Returns
842 -------
843 dictionary : `dict`
844 Dictionary of properties.
845 """
846 self.updateMetadata()
847 outDict = {}
848 outDict['metadata'] = self.getMetadata()
849
850 outDict['driftScale'] = self.driftScale
851 outDict['decayTime'] = self.decayTime
852 outDict['globalCti'] = self.globalCti
853
854 outDict['serialTraps'] = {}
855 for ampName in self.serialTraps:
856 ampTrap = {'size': self.serialTraps[ampName].size,
857 'emissionTime': self.serialTraps[ampName].emission_time,
858 'pixel': self.serialTraps[ampName].pixel,
859 'trap_type': self.serialTraps[ampName].trap_type,
860 'coeffs': self.serialTraps[ampName].coeffs}
861 outDict['serialTraps'][ampName] = ampTrap
862
863 return outDict
864
865 @classmethod
866 def fromTable(cls, tableList):
867 """Construct calibration from a list of tables.
868
869 This method uses the ``fromDict`` method to create the
870 calibration, after constructing an appropriate dictionary from
871 the input tables.
872
873 Parameters
874 ----------
875 tableList : `list` [`lsst.afw.table.Table`]
876 List of tables to use to construct the CTI
877 calibration. Two tables are expected in this list, the
878 first containing the per-amplifier CTI parameters, and the
879 second containing the parameters for serial traps.
880
881 Returns
882 -------
883 calib : `lsst.ip.isr.DeferredChargeCalib`
884 The calibration defined in the tables.
885
886 Raises
887 ------
888 ValueError
889 Raised if the trap type or trap coefficients are not
890 defined properly.
891 """
892 ampTable = tableList[0]
893
894 inDict = {}
895 inDict['metadata'] = ampTable.meta
896 calibVersion = inDict['metadata']['CTI_VERSION']
897
898 amps = ampTable['AMPLIFIER']
899 driftScale = ampTable['DRIFT_SCALE']
900 decayTime = ampTable['DECAY_TIME']
901 globalCti = ampTable['GLOBAL_CTI']
902
903 inDict['driftScale'] = {amp: value for amp, value in zip(amps, driftScale)}
904 inDict['decayTime'] = {amp: value for amp, value in zip(amps, decayTime)}
905 inDict['globalCti'] = {amp: value for amp, value in zip(amps, globalCti)}
906
907 inDict['serialTraps'] = {}
908 trapTable = tableList[1]
909
910 amps = trapTable['AMPLIFIER']
911 sizes = trapTable['SIZE']
912 emissionTimes = trapTable['EMISSION_TIME']
913 pixels = trapTable['PIXEL']
914 trap_type = trapTable['TYPE']
915 coeffs = trapTable['COEFFS']
916
917 for index, amp in enumerate(amps):
918 ampTrap = {}
919 ampTrap['size'] = sizes[index]
920 ampTrap['emissionTime'] = emissionTimes[index]
921 ampTrap['pixel'] = pixels[index]
922 ampTrap['trap_type'] = trap_type[index]
923
924 # Unpad any trailing NaN values: find the continuous array
925 # of NaNs at the end of the coefficients, and remove them.
926 inCoeffs = coeffs[index]
927 breakIndex = 1
928 nanValues = np.where(np.isnan(inCoeffs))[0]
929 if nanValues is not None:
930 coeffLength = len(inCoeffs)
931 while breakIndex < coeffLength:
932 if coeffLength - breakIndex in nanValues:
933 breakIndex += 1
934 else:
935 break
936 breakIndex -= 1 # Remove the fixed offset.
937 if breakIndex != 0:
938 outCoeffs = inCoeffs[0: coeffLength - breakIndex]
939 else:
940 outCoeffs = inCoeffs
941 ampTrap['coeffs'] = outCoeffs.tolist()
942
943 if ampTrap['trap_type'] == 'linear':
944 if len(ampTrap['coeffs']) < 1:
945 raise ValueError("CTI Amplifier %s coefficients for trap has illegal length %d.",
946 amp, len(ampTrap['coeffs']))
947 elif ampTrap['trap_type'] == 'logistic':
948 if len(ampTrap['coeffs']) < 2:
949 raise ValueError("CTI Amplifier %s coefficients for trap has illegal length %d.",
950 amp, len(ampTrap['coeffs']))
951 elif ampTrap['trap_type'] == 'spline':
952 if len(ampTrap['coeffs']) % 2 != 0:
953 raise ValueError("CTI Amplifier %s coefficients for trap has illegal length %d.",
954 amp, len(ampTrap['coeffs']))
955 else:
956 raise ValueError('Unknown trap type: %s', ampTrap['trap_type'])
957
958 inDict['serialTraps'][amp] = ampTrap
959
960 # Version check
961 if calibVersion < 1.1:
962 # This version might be in the wrong units (not electron),
963 # and does not contain the gain information to convert
964 # into a new calibration version.
965 raise RuntimeError(f"Using old version of CTI calibration (ver. {calibVersion} < 1.1), "
966 "which is no longer supported.")
967
968 return cls.fromDictfromDict(inDict)
969
970 def toTable(self):
971 """Construct a list of tables containing the information in this
972 calibration.
973
974 The list of tables should create an identical calibration
975 after being passed to this class's fromTable method.
976
977 Returns
978 -------
979 tableList : `list` [`lsst.afw.table.Table`]
980 List of tables containing the crosstalk calibration
981 information. Two tables are generated for this list, the
982 first containing the per-amplifier CTI parameters, and the
983 second containing the parameters for serial traps.
984 """
985 tableList = []
986 self.updateMetadata()
987
988 ampList = []
989 driftScale = []
990 decayTime = []
991 globalCti = []
992
993 for amp in self.driftScale.keys():
994 ampList.append(amp)
995 driftScale.append(self.driftScale[amp])
996 decayTime.append(self.decayTime[amp])
997 globalCti.append(self.globalCti[amp])
998
999 ampTable = Table({'AMPLIFIER': ampList,
1000 'DRIFT_SCALE': driftScale,
1001 'DECAY_TIME': decayTime,
1002 'GLOBAL_CTI': globalCti,
1003 })
1004
1005 ampTable.meta = self.getMetadata().toDict()
1006 tableList.append(ampTable)
1007
1008 ampList = []
1009 sizeList = []
1010 timeList = []
1011 pixelList = []
1012 typeList = []
1013 coeffList = []
1014
1015 # Get maximum coeff length
1016 maxCoeffLength = 0
1017 for trap in self.serialTraps.values():
1018 maxCoeffLength = np.maximum(maxCoeffLength, len(trap.coeffs))
1019
1020 # Pack and pad the end of the coefficients with NaN values.
1021 for amp, trap in self.serialTraps.items():
1022 ampList.append(amp)
1023 sizeList.append(trap.size)
1024 timeList.append(trap.emission_time)
1025 pixelList.append(trap.pixel)
1026 typeList.append(trap.trap_type)
1027
1028 coeffs = trap.coeffs
1029 if len(coeffs) != maxCoeffLength:
1030 coeffs = np.pad(coeffs, (0, maxCoeffLength - len(coeffs)),
1031 constant_values=np.nan).tolist()
1032 coeffList.append(coeffs)
1033
1034 trapTable = Table({'AMPLIFIER': ampList,
1035 'SIZE': sizeList,
1036 'EMISSION_TIME': timeList,
1037 'PIXEL': pixelList,
1038 'TYPE': typeList,
1039 'COEFFS': coeffList})
1040
1041 tableList.append(trapTable)
1042
1043 return tableList
1044
1045
1047 """Settings for deferred charge correction.
1048 """
1049 nPixelOffsetCorrection = Field(
1050 dtype=int,
1051 doc="Number of prior pixels to use for local offset correction.",
1052 default=15,
1053 )
1054 nPixelTrapCorrection = Field(
1055 dtype=int,
1056 doc="Number of prior pixels to use for trap correction.",
1057 default=6,
1058 )
1059 useGains = Field(
1060 dtype=bool,
1061 doc="If true, scale by the gain.",
1062 default=False,
1063 # TODO: DM-46721
1064 deprecated="This field is no longer used. Will be removed after v28.",
1065 )
1066 zeroUnusedPixels = Field(
1067 dtype=bool,
1068 doc="If true, set serial prescan and parallel overscan to zero before correction.",
1069 default=False,
1070 )
1071
1072
1074 """Task to correct an exposure for charge transfer inefficiency.
1075
1076 This uses the methods described by Snyder et al. 2021, Journal of
1077 Astronimcal Telescopes, Instruments, and Systems, 7,
1078 048002. doi:10.1117/1.JATIS.7.4.048002 (Snyder+21).
1079 """
1080 ConfigClass = DeferredChargeConfig
1081 _DefaultName = 'isrDeferredCharge'
1082
1083 def run(self, exposure, ctiCalib, gains=None):
1084 """Correct deferred charge/CTI issues.
1085
1086 Parameters
1087 ----------
1088 exposure : `lsst.afw.image.Exposure`
1089 Exposure to correct the deferred charge on.
1090 ctiCalib : `lsst.ip.isr.DeferredChargeCalib`
1091 Calibration object containing the charge transfer
1092 inefficiency model.
1093 gains : `dict` [`str`, `float`]
1094 A dictionary, keyed by amplifier name, of the gains to
1095 use. If gains is None, the nominal gains in the amplifier
1096 object are used.
1097
1098 Returns
1099 -------
1100 exposure : `lsst.afw.image.Exposure`
1101 The corrected exposure.
1102
1103 Notes
1104 -------
1105 This task will read the exposure metadata and determine if
1106 applying gains if necessary. The correction takes place in
1107 units of electrons. If bootstrapping, the gains used
1108 will just be 1.0. and the input/output units will stay in
1109 adu. If the input image is in adu, the output image will be
1110 in units of electrons. If the input image is in electron,
1111 the output image will be in electron.
1112 """
1113 image = exposure.getMaskedImage().image
1114 detector = exposure.getDetector()
1115
1116 # Get the image and overscan units.
1117 imageUnits = exposure.getMetadata().get("LSST ISR UNITS")
1118
1119 # The deferred charge correction assumes that everything is in
1120 # electron units. Make it so:
1121 applyGains = False
1122 if imageUnits == "adu":
1123 applyGains = True
1124
1125 # If we need to convert the image to electrons, check that gains
1126 # were supplied. CTI should not be solved or corrected without
1127 # supplied gains.
1128 if applyGains:
1129 if gains is None:
1130 raise RuntimeError("No gains supplied for deferred charge correction.")
1131
1132 with gainContext(exposure, image, apply=applyGains, gains=gains, isTrimmed=False):
1133 # Both the image and the overscan are in electron units.
1134 for amp in detector.getAmplifiers():
1135 ampName = amp.getName()
1136
1137 ampImage = image[amp.getRawBBox()]
1138 if self.config.zeroUnusedPixels:
1139 # We don't apply overscan subtraction, so zero these
1140 # out for now.
1141 ampImage[amp.getRawParallelOverscanBBox()].array[:, :] = 0.0
1142 ampImage[amp.getRawSerialPrescanBBox()].array[:, :] = 0.0
1143
1144 # The algorithm expects that the readout corner is in
1145 # the lower left corner. Flip it to be so:
1146 ampData = self.flipData(ampImage.array, amp)
1147
1148 if ctiCalib.driftScale[ampName] > 0.0:
1149 correctedAmpData = self.local_offset_inverse(ampData,
1150 ctiCalib.driftScale[ampName],
1151 ctiCalib.decayTime[ampName],
1152 self.config.nPixelOffsetCorrection)
1153 else:
1154 correctedAmpData = ampData.copy()
1155
1156 correctedAmpData = self.local_trap_inverse(correctedAmpData,
1157 ctiCalib.serialTraps[ampName],
1158 ctiCalib.globalCti[ampName],
1159 self.config.nPixelTrapCorrection)
1160
1161 # Undo flips here. The method is symmetric.
1162 correctedAmpData = self.flipData(correctedAmpData, amp)
1163 image[amp.getRawBBox()].array[:, :] = correctedAmpData[:, :]
1164
1165 return exposure
1166
1167 @staticmethod
1168 def flipData(ampData, amp):
1169 """Flip data array such that readout corner is at lower-left.
1170
1171 Parameters
1172 ----------
1173 ampData : `numpy.ndarray`, (nx, ny)
1174 Image data to flip.
1175 amp : `lsst.afw.cameraGeom.Amplifier`
1176 Amplifier to get readout corner information.
1177
1178 Returns
1179 -------
1180 ampData : `numpy.ndarray`, (nx, ny)
1181 Flipped image data.
1182 """
1183 X_FLIP = {ReadoutCorner.LL: False,
1184 ReadoutCorner.LR: True,
1185 ReadoutCorner.UL: False,
1186 ReadoutCorner.UR: True}
1187 Y_FLIP = {ReadoutCorner.LL: False,
1188 ReadoutCorner.LR: False,
1189 ReadoutCorner.UL: True,
1190 ReadoutCorner.UR: True}
1191
1192 if X_FLIP[amp.getReadoutCorner()]:
1193 ampData = np.fliplr(ampData)
1194 if Y_FLIP[amp.getReadoutCorner()]:
1195 ampData = np.flipud(ampData)
1196
1197 return ampData
1198
1199 @staticmethod
1200 def local_offset_inverse(inputArr, drift_scale, decay_time, num_previous_pixels=15):
1201 r"""Remove CTI effects from local offsets.
1202
1203 This implements equation 10 of Snyder+21. For an image with
1204 CTI, s'(m, n), the correction factor is equal to the maximum
1205 value of the set of:
1206
1207 .. code-block::
1208
1209 {A_L s'(m, n - j) exp(-j t / \tau_L)}_j=0^jmax
1210
1211 Parameters
1212 ----------
1213 inputArr : `numpy.ndarray`, (nx, ny)
1214 Input image data to correct.
1215 drift_scale : `float`
1216 Drift scale (Snyder+21 A_L value) to use in correction.
1217 decay_time : `float`
1218 Decay time (Snyder+21 \tau_L) of the correction.
1219 num_previous_pixels : `int`, optional
1220 Number of previous pixels to use for correction. As the
1221 CTI has an exponential decay, this essentially truncates
1222 the correction where that decay scales the input charge to
1223 near zero.
1224
1225 Returns
1226 -------
1227 outputArr : `numpy.ndarray`, (nx, ny)
1228 Corrected image data.
1229 """
1230 r = np.exp(-1/decay_time)
1231 Ny, Nx = inputArr.shape
1232
1233 # j = 0 term:
1234 offset = np.zeros((num_previous_pixels, Ny, Nx))
1235 offset[0, :, :] = drift_scale*np.maximum(0, inputArr)
1236
1237 # j = 1..jmax terms:
1238 for n in range(1, num_previous_pixels):
1239 offset[n, :, n:] = drift_scale*np.maximum(0, inputArr[:, :-n])*(r**n)
1240
1241 Linv = np.amax(offset, axis=0)
1242 outputArr = inputArr - Linv
1243
1244 return outputArr
1245
1246 @staticmethod
1247 def local_trap_inverse(inputArr, trap, global_cti=0.0, num_previous_pixels=6):
1248 r"""Apply localized trapping inverse operator to pixel signals.
1249
1250 This implements equation 13 of Snyder+21. For an image with
1251 CTI, s'(m, n), the correction factor is equal to the maximum
1252 value of the set of:
1253
1254 .. code-block::
1255
1256 {A_L s'(m, n - j) exp(-j t / \tau_L)}_j=0^jmax
1257
1258 Parameters
1259 ----------
1260 inputArr : `numpy.ndarray`, (nx, ny)
1261 Input image data to correct.
1262 trap : `lsst.ip.isr.SerialTrap`
1263 Serial trap describing the capture and release of charge.
1264 global_cti: `float`
1265 Mean charge transfer inefficiency, b from Snyder+21.
1266 num_previous_pixels : `int`, optional
1267 Number of previous pixels to use for correction.
1268
1269 Returns
1270 -------
1271 outputArr : `numpy.ndarray`, (nx, ny)
1272 Corrected image data.
1273
1274 """
1275 Ny, Nx = inputArr.shape
1276 a = 1 - global_cti
1277 r = np.exp(-1/trap.emission_time)
1278
1279 # Estimate trap occupancies during readout
1280 trap_occupancy = np.zeros((num_previous_pixels, Ny, Nx))
1281 for n in range(num_previous_pixels):
1282 trap_occupancy[n, :, n+1:] = trap.capture(np.maximum(0, inputArr))[:, :-(n+1)]*(r**n)
1283 trap_occupancy = np.amax(trap_occupancy, axis=0)
1284
1285 # Estimate captured charge
1286 C = trap.capture(np.maximum(0, inputArr)) - trap_occupancy*r
1287 C[C < 0] = 0.
1288
1289 # Estimate released charge
1290 R = np.zeros(inputArr.shape)
1291 R[:, 1:] = trap_occupancy[:, 1:]*(1-r)
1292 T = R - C
1293
1294 outputArr = inputArr - a*T
1295
1296 return outputArr
std::vector< SchemaItem< Flag > > * items
fromDict(cls, dictionary, **kwargs)
Definition calibType.py:575
updateMetadata(self, camera=None, detector=None, filterName=None, setCalibId=False, setCalibInfo=False, setDate=False, **kwargs)
Definition calibType.py:207
local_trap_inverse(inputArr, trap, global_cti=0.0, num_previous_pixels=6)
local_offset_inverse(inputArr, drift_scale, decay_time, num_previous_pixels=15)
run(self, exposure, ctiCalib, gains=None)
__init__(self, gain, scale, decay_time, noise=0.0, offset=0.0)
negative_loglikelihood(self, params, signal, data, error, *args, **kwargs)
loglikelihood(self, params, signal, data, error, *args, **kwargs)
rms_error(self, params, signal, data, error, *args, **kwargs)
difference(self, params, signal, data, error, *args, **kwargs)
model_results(params, signal, num_transfers, start=1, stop=10)
readout(self, serial_overscan_width=10, parallel_overscan_width=0)
__init__(self, imarr, prescan_width, output_amplifier, cti=0.0, traps=None)
__init__(self, size, emission_time, pixel, trap_type, coeffs)
initialize(self, ny, nx, prescan_width)
model_results(params, signal, num_transfers, start=1, stop=10)
model_results(params, signal, num_transfers, amp, start=1, stop=10, trap_type=None)