Coverage for python/lsst/ip/isr/deferredCharge.py: 16%

270 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-12 10:46 +0000

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', 'DeferredChargeTask', 'SerialTrap', 'DeferredChargeCalib') 

23 

24import numpy as np 

25from astropy.table import Table 

26 

27from lsst.afw.cameraGeom import ReadoutCorner 

28from lsst.pex.config import Config, Field 

29from lsst.pipe.base import Task 

30from .isrFunctions import gainContext 

31from .calibType import IsrCalib 

32 

33import scipy.interpolate as interp 

34 

35 

36class SerialTrap(): 

37 """Represents a serial register trap. 

38 

39 Parameters 

40 ---------- 

41 size : `float` 

42 Size of the charge trap, in electrons. 

43 emission_time : `float` 

44 Trap emission time constant, in inverse transfers. 

45 pixel : `int` 

46 Serial pixel location of the trap, including the prescan. 

47 trap_type : `str` 

48 Type of trap capture to use. Should be one of ``linear``, 

49 ``logistic``, or ``spline``. 

50 coeffs : `list` [`float`] 

51 Coefficients for the capture process. Linear traps need one 

52 coefficient, logistic traps need two, and spline based traps 

53 need to have an even number of coefficients that can be split 

54 into their spline locations and values. 

55 

56 Raises 

57 ------ 

58 ValueError 

59 Raised if the specified parameters are out of expected range. 

60 """ 

61 

62 def __init__(self, size, emission_time, pixel, trap_type, coeffs): 

63 if size < 0.0: 

64 raise ValueError('Trap size must be greater than or equal to 0.') 

65 self.size = size 

66 

67 if emission_time <= 0.0: 

68 raise ValueError('Emission time must be greater than 0.') 

69 if np.isnan(emission_time): 

70 raise ValueError('Emission time must be real-valued, not NaN') 

71 self.emission_time = emission_time 

72 

73 if int(pixel) != pixel: 

74 raise ValueError('Fraction value for pixel not allowed.') 

75 self.pixel = int(pixel) 

76 

77 self.trap_type = trap_type 

78 self.coeffs = coeffs 

79 

80 if self.trap_type not in ('linear', 'logistic', 'spline'): 

81 raise ValueError('Unknown trap type: %s', self.trap_type) 

82 

83 if self.trap_type == 'spline': 

84 centers, values = np.split(np.array(self.coeffs, dtype=np.float64), 2) 

85 # Ensure all NaN values are stripped out 

86 values = values[~np.isnan(centers)] 

87 centers = centers[~np.isnan(centers)] 

88 centers = centers[~np.isnan(values)] 

89 values = values[~np.isnan(values)] 

90 self.interp = interp.interp1d(centers, values) 

91 

92 self._trap_array = None 

93 self._trapped_charge = None 

94 

95 def __eq__(self, other): 

96 # A trap is equal to another trap if all of the initialization 

97 # parameters are equal. All other properties are only filled 

98 # during use, and are not persisted into the calibration. 

99 if self.size != other.size: 

100 return False 

101 if self.emission_time != other.emission_time: 

102 return False 

103 if self.pixel != other.pixel: 

104 return False 

105 if self.trap_type != other.trap_type: 

106 return False 

107 if self.coeffs != other.coeffs: 

108 return False 

109 return True 

110 

111 @property 

112 def trap_array(self): 

113 return self._trap_array 

114 

115 @property 

116 def trapped_charge(self): 

117 return self._trapped_charge 

118 

119 def initialize(self, ny, nx, prescan_width): 

120 """Initialize trapping arrays for simulated readout. 

121 

122 Parameters 

123 ---------- 

124 ny : `int` 

125 Number of rows to simulate. 

126 nx : `int` 

127 Number of columns to simulate. 

128 prescan_width : `int` 

129 Additional transfers due to prescan. 

130 

131 Raises 

132 ------ 

133 ValueError 

134 Raised if the trap falls outside of the image. 

135 """ 

136 if self.pixel > nx+prescan_width: 

137 raise ValueError('Trap location {0} must be less than {1}'.format(self.pixel, 

138 nx+prescan_width)) 

139 

140 self._trap_array = np.zeros((ny, nx+prescan_width)) 

141 self._trap_array[:, self.pixel] = self.size 

142 self._trapped_charge = np.zeros((ny, nx+prescan_width)) 

143 

144 def release_charge(self): 

145 """Release charge through exponential decay. 

146 

147 Returns 

148 ------- 

149 released_charge : `float` 

150 Charge released. 

151 """ 

152 released_charge = self._trapped_charge*(1-np.exp(-1./self.emission_time)) 

153 self._trapped_charge -= released_charge 

154 

155 return released_charge 

156 

157 def trap_charge(self, free_charge): 

158 """Perform charge capture using a logistic function. 

159 

160 Parameters 

161 ---------- 

162 free_charge : `float` 

163 Charge available to be trapped. 

164 

165 Returns 

166 ------- 

167 captured_charge : `float` 

168 Amount of charge actually trapped. 

169 """ 

170 captured_charge = (np.clip(self.capture(free_charge), self.trapped_charge, self._trap_array) 

171 - self.trapped_charge) 

172 self._trapped_charge += captured_charge 

173 

174 return captured_charge 

175 

176 def capture(self, pixel_signals): 

177 """Trap capture function. 

178 

179 Parameters 

180 ---------- 

181 pixel_signals : `list` [`float`] 

182 Input pixel values. 

183 

184 Returns 

185 ------- 

186 captured_charge : `list` [`float`] 

187 Amount of charge captured from each pixel. 

188 

189 Raises 

190 ------ 

191 RuntimeError 

192 Raised if the trap type is invalid. 

193 """ 

194 if self.trap_type == 'linear': 

195 scaling = self.coeffs[0] 

196 return np.minimum(self.size, pixel_signals*scaling) 

197 elif self.trap_type == 'logistic': 

198 f0, k = (self.coeffs[0], self.coeffs[1]) 

199 return self.size/(1.+np.exp(-k*(pixel_signals-f0))) 

200 elif self.trap_type == 'spline': 

201 return self.interp(pixel_signals) 

202 else: 

203 raise RuntimeError(f"Invalid trap capture type: {self.trap_type}.") 

204 

205 

206class DeferredChargeCalib(IsrCalib): 

207 r"""Calibration containing deferred charge/CTI parameters. 

208 

209 Parameters 

210 ---------- 

211 **kwargs : 

212 Additional parameters to pass to parent constructor. 

213 

214 Notes 

215 ----- 

216 The charge transfer inefficiency attributes stored are: 

217 

218 driftScale : `dict` [`str`, `float`] 

219 A dictionary, keyed by amplifier name, of the local electronic 

220 offset drift scale parameter, A_L in Snyder+2021. 

221 decayTime : `dict` [`str`, `float`] 

222 A dictionary, keyed by amplifier name, of the local electronic 

223 offset decay time, \tau_L in Snyder+2021. 

224 globalCti : `dict` [`str`, `float`] 

225 A dictionary, keyed by amplifier name, of the mean global CTI 

226 paramter, b in Snyder+2021. 

227 serialTraps : `dict` [`str`, `lsst.ip.isr.SerialTrap`] 

228 A dictionary, keyed by amplifier name, containing a single 

229 serial trap for each amplifier. 

230 """ 

231 _OBSTYPE = 'CTI' 

232 _SCHEMA = 'Deferred Charge' 

233 _VERSION = 1.0 

234 

235 def __init__(self, **kwargs): 

236 self.driftScale = {} 

237 self.decayTime = {} 

238 self.globalCti = {} 

239 self.serialTraps = {} 

240 

241 super().__init__(**kwargs) 

242 self.requiredAttributes.update(['driftScale', 'decayTime', 'globalCti', 'serialTraps']) 

243 

244 def fromDetector(self, detector): 

245 """Read metadata parameters from a detector. 

246 

247 Parameters 

248 ---------- 

249 detector : `lsst.afw.cameraGeom.detector` 

250 Input detector with parameters to use. 

251 

252 Returns 

253 ------- 

254 calib : `lsst.ip.isr.Linearizer` 

255 The calibration constructed from the detector. 

256 """ 

257 

258 pass 

259 

260 @classmethod 

261 def fromDict(cls, dictionary): 

262 """Construct a calibration from a dictionary of properties. 

263 

264 Parameters 

265 ---------- 

266 dictionary : `dict` 

267 Dictionary of properties. 

268 

269 Returns 

270 ------- 

271 calib : `lsst.ip.isr.CalibType` 

272 Constructed calibration. 

273 

274 Raises 

275 ------ 

276 RuntimeError 

277 Raised if the supplied dictionary is for a different 

278 calibration. 

279 """ 

280 calib = cls() 

281 

282 if calib._OBSTYPE != dictionary['metadata']['OBSTYPE']: 

283 raise RuntimeError(f"Incorrect CTI supplied. Expected {calib._OBSTYPE}, " 

284 f"found {dictionary['metadata']['OBSTYPE']}") 

285 

286 calib.setMetadata(dictionary['metadata']) 

287 

288 calib.driftScale = dictionary['driftScale'] 

289 calib.decayTime = dictionary['decayTime'] 

290 calib.globalCti = dictionary['globalCti'] 

291 

292 for ampName in dictionary['serialTraps']: 

293 ampTraps = dictionary['serialTraps'][ampName] 

294 calib.serialTraps[ampName] = SerialTrap(ampTraps['size'], ampTraps['emissionTime'], 

295 ampTraps['pixel'], ampTraps['trap_type'], 

296 ampTraps['coeffs']) 

297 calib.updateMetadata() 

298 return calib 

299 

300 def toDict(self): 

301 """Return a dictionary containing the calibration properties. 

302 The dictionary should be able to be round-tripped through 

303 ``fromDict``. 

304 

305 Returns 

306 ------- 

307 dictionary : `dict` 

308 Dictionary of properties. 

309 """ 

310 self.updateMetadata() 

311 outDict = {} 

312 outDict['metadata'] = self.getMetadata() 

313 

314 outDict['driftScale'] = self.driftScale 

315 outDict['decayTime'] = self.decayTime 

316 outDict['globalCti'] = self.globalCti 

317 

318 outDict['serialTraps'] = {} 

319 for ampName in self.serialTraps: 

320 ampTrap = {'size': self.serialTraps[ampName].size, 

321 'emissionTime': self.serialTraps[ampName].emission_time, 

322 'pixel': self.serialTraps[ampName].pixel, 

323 'trap_type': self.serialTraps[ampName].trap_type, 

324 'coeffs': self.serialTraps[ampName].coeffs} 

325 outDict['serialTraps'][ampName] = ampTrap 

326 

327 return outDict 

328 

329 @classmethod 

330 def fromTable(cls, tableList): 

331 """Construct calibration from a list of tables. 

332 

333 This method uses the ``fromDict`` method to create the 

334 calibration, after constructing an appropriate dictionary from 

335 the input tables. 

336 

337 Parameters 

338 ---------- 

339 tableList : `list` [`lsst.afw.table.Table`] 

340 List of tables to use to construct the crosstalk 

341 calibration. Two tables are expected in this list, the 

342 first containing the per-amplifier CTI parameters, and the 

343 second containing the parameters for serial traps. 

344 

345 Returns 

346 ------- 

347 calib : `lsst.ip.isr.DeferredChargeCalib` 

348 The calibration defined in the tables. 

349 

350 Raises 

351 ------ 

352 ValueError 

353 Raised if the trap type or trap coefficients are not 

354 defined properly. 

355 """ 

356 ampTable = tableList[0] 

357 

358 inDict = {} 

359 inDict['metadata'] = ampTable.meta 

360 

361 amps = ampTable['AMPLIFIER'] 

362 driftScale = ampTable['DRIFT_SCALE'] 

363 decayTime = ampTable['DECAY_TIME'] 

364 globalCti = ampTable['GLOBAL_CTI'] 

365 

366 inDict['driftScale'] = {amp: value for amp, value in zip(amps, driftScale)} 

367 inDict['decayTime'] = {amp: value for amp, value in zip(amps, decayTime)} 

368 inDict['globalCti'] = {amp: value for amp, value in zip(amps, globalCti)} 

369 

370 inDict['serialTraps'] = {} 

371 trapTable = tableList[1] 

372 

373 amps = trapTable['AMPLIFIER'] 

374 sizes = trapTable['SIZE'] 

375 emissionTimes = trapTable['EMISSION_TIME'] 

376 pixels = trapTable['PIXEL'] 

377 trap_type = trapTable['TYPE'] 

378 coeffs = trapTable['COEFFS'] 

379 

380 for index, amp in enumerate(amps): 

381 ampTrap = {} 

382 ampTrap['size'] = sizes[index] 

383 ampTrap['emissionTime'] = emissionTimes[index] 

384 ampTrap['pixel'] = pixels[index] 

385 ampTrap['trap_type'] = trap_type[index] 

386 

387 # Unpad any trailing NaN values: find the continuous array 

388 # of NaNs at the end of the coefficients, and remove them. 

389 inCoeffs = coeffs[index] 

390 breakIndex = 1 

391 nanValues = np.where(np.isnan(inCoeffs))[0] 

392 if nanValues is not None: 

393 coeffLength = len(inCoeffs) 

394 while breakIndex < coeffLength: 

395 if coeffLength - breakIndex in nanValues: 

396 breakIndex += 1 

397 else: 

398 break 

399 breakIndex -= 1 # Remove the fixed offset. 

400 if breakIndex != 0: 

401 outCoeffs = inCoeffs[0: coeffLength - breakIndex] 

402 else: 

403 outCoeffs = inCoeffs 

404 ampTrap['coeffs'] = outCoeffs.tolist() 

405 

406 if ampTrap['trap_type'] == 'linear': 

407 if len(ampTrap['coeffs']) < 1: 

408 raise ValueError("CTI Amplifier %s coefficients for trap has illegal length %d.", 

409 amp, len(ampTrap['coeffs'])) 

410 elif ampTrap['trap_type'] == 'logistic': 

411 if len(ampTrap['coeffs']) < 2: 

412 raise ValueError("CTI Amplifier %s coefficients for trap has illegal length %d.", 

413 amp, len(ampTrap['coeffs'])) 

414 elif ampTrap['trap_type'] == 'spline': 

415 if len(ampTrap['coeffs']) % 2 != 0: 

416 raise ValueError("CTI Amplifier %s coefficients for trap has illegal length %d.", 

417 amp, len(ampTrap['coeffs'])) 

418 else: 

419 raise ValueError('Unknown trap type: %s', ampTrap['trap_type']) 

420 

421 inDict['serialTraps'][amp] = ampTrap 

422 

423 return cls.fromDict(inDict) 

424 

425 def toTable(self): 

426 """Construct a list of tables containing the information in this 

427 calibration. 

428 

429 The list of tables should create an identical calibration 

430 after being passed to this class's fromTable method. 

431 

432 Returns 

433 ------- 

434 tableList : `list` [`lsst.afw.table.Table`] 

435 List of tables containing the crosstalk calibration 

436 information. Two tables are generated for this list, the 

437 first containing the per-amplifier CTI parameters, and the 

438 second containing the parameters for serial traps. 

439 """ 

440 tableList = [] 

441 self.updateMetadata() 

442 

443 ampList = [] 

444 driftScale = [] 

445 decayTime = [] 

446 globalCti = [] 

447 

448 for amp in self.driftScale.keys(): 

449 ampList.append(amp) 

450 driftScale.append(self.driftScale[amp]) 

451 decayTime.append(self.decayTime[amp]) 

452 globalCti.append(self.globalCti[amp]) 

453 

454 ampTable = Table({'AMPLIFIER': ampList, 

455 'DRIFT_SCALE': driftScale, 

456 'DECAY_TIME': decayTime, 

457 'GLOBAL_CTI': globalCti, 

458 }) 

459 

460 ampTable.meta = self.getMetadata().toDict() 

461 tableList.append(ampTable) 

462 

463 ampList = [] 

464 sizeList = [] 

465 timeList = [] 

466 pixelList = [] 

467 typeList = [] 

468 coeffList = [] 

469 

470 # Get maximum coeff length 

471 maxCoeffLength = 0 

472 for trap in self.serialTraps.values(): 

473 maxCoeffLength = np.maximum(maxCoeffLength, len(trap.coeffs)) 

474 

475 # Pack and pad the end of the coefficients with NaN values. 

476 for amp, trap in self.serialTraps.items(): 

477 ampList.append(amp) 

478 sizeList.append(trap.size) 

479 timeList.append(trap.emission_time) 

480 pixelList.append(trap.pixel) 

481 typeList.append(trap.trap_type) 

482 

483 coeffs = trap.coeffs 

484 if len(coeffs) != maxCoeffLength: 

485 coeffs = np.pad(coeffs, (0, maxCoeffLength - len(coeffs)), 

486 constant_values=np.nan).tolist() 

487 coeffList.append(coeffs) 

488 

489 trapTable = Table({'AMPLIFIER': ampList, 

490 'SIZE': sizeList, 

491 'EMISSION_TIME': timeList, 

492 'PIXEL': pixelList, 

493 'TYPE': typeList, 

494 'COEFFS': coeffList}) 

495 

496 tableList.append(trapTable) 

497 

498 return tableList 

499 

500 

501class DeferredChargeConfig(Config): 

502 """Settings for deferred charge correction. 

503 """ 

504 nPixelOffsetCorrection = Field( 

505 dtype=int, 

506 doc="Number of prior pixels to use for local offset correction.", 

507 default=15, 

508 ) 

509 nPixelTrapCorrection = Field( 

510 dtype=int, 

511 doc="Number of prior pixels to use for trap correction.", 

512 default=6, 

513 ) 

514 useGains = Field( 

515 dtype=bool, 

516 doc="If true, scale by the gain.", 

517 default=False, 

518 ) 

519 zeroUnusedPixels = Field( 

520 dtype=bool, 

521 doc="If true, set serial prescan and parallel overscan to zero before correction.", 

522 default=False, 

523 ) 

524 

525 

526class DeferredChargeTask(Task): 

527 """Task to correct an exposure for charge transfer inefficiency. 

528 

529 This uses the methods described by Snyder et al. 2021, Journal of 

530 Astronimcal Telescopes, Instruments, and Systems, 7, 

531 048002. doi:10.1117/1.JATIS.7.4.048002 (Snyder+21). 

532 """ 

533 ConfigClass = DeferredChargeConfig 

534 _DefaultName = 'isrDeferredCharge' 

535 

536 def run(self, exposure, ctiCalib, gains=None): 

537 """Correct deferred charge/CTI issues. 

538 

539 Parameters 

540 ---------- 

541 exposure : `lsst.afw.image.Exposure` 

542 Exposure to correct the deferred charge on. 

543 ctiCalib : `lsst.ip.isr.DeferredChargeCalib` 

544 Calibration object containing the charge transfer 

545 inefficiency model. 

546 gains : `dict` [`str`, `float`] 

547 A dictionary, keyed by amplifier name, of the gains to 

548 use. If gains is None, the nominal gains in the amplifier 

549 object are used. 

550 

551 Returns 

552 ------- 

553 exposure : `lsst.afw.image.Exposure` 

554 The corrected exposure. 

555 """ 

556 image = exposure.getMaskedImage().image 

557 detector = exposure.getDetector() 

558 

559 # If gains were supplied, they should be used. If useGains is 

560 # true, but no external gains were supplied, use the nominal 

561 # gains listed in the detector. Finally, if useGains is 

562 # false, fake a dictionary of unit gains for ``gainContext``. 

563 if self.config.useGains: 

564 if gains is None: 

565 gains = {amp.getName(): amp.getGain() for amp in detector.getAmplifiers()} 

566 

567 with gainContext(exposure, image, self.config.useGains, gains): 

568 for amp in detector.getAmplifiers(): 

569 ampName = amp.getName() 

570 

571 ampImage = image[amp.getRawBBox()] 

572 if self.config.zeroUnusedPixels: 

573 # We don't apply overscan subtraction, so zero these 

574 # out for now. 

575 ampImage[amp.getRawParallelOverscanBBox()].array[:, :] = 0.0 

576 ampImage[amp.getRawSerialPrescanBBox()].array[:, :] = 0.0 

577 

578 # The algorithm expects that the readout corner is in 

579 # the lower left corner. Flip it to be so: 

580 

581 ampData = self.flipData(ampImage.array, amp) 

582 

583 if ctiCalib.driftScale[ampName] > 0.0: 

584 correctedAmpData = self.local_offset_inverse(ampData, 

585 ctiCalib.driftScale[ampName], 

586 ctiCalib.decayTime[ampName], 

587 self.config.nPixelOffsetCorrection) 

588 else: 

589 correctedAmpData = ampData.copy() 

590 

591 correctedAmpData = self.local_trap_inverse(correctedAmpData, 

592 ctiCalib.serialTraps[ampName], 

593 ctiCalib.globalCti[ampName], 

594 self.config.nPixelTrapCorrection) 

595 

596 # Undo flips here. The method is symmetric. 

597 correctedAmpData = self.flipData(correctedAmpData, amp) 

598 image[amp.getRawBBox()].array[:, :] = correctedAmpData[:, :] 

599 

600 return exposure 

601 

602 @staticmethod 

603 def flipData(ampData, amp): 

604 """Flip data array such that readout corner is at lower-left. 

605 

606 Parameters 

607 ---------- 

608 ampData : `numpy.ndarray`, (nx, ny) 

609 Image data to flip. 

610 amp : `lsst.afw.cameraGeom.Amplifier` 

611 Amplifier to get readout corner information. 

612 

613 Returns 

614 ------- 

615 ampData : `numpy.ndarray`, (nx, ny) 

616 Flipped image data. 

617 """ 

618 X_FLIP = {ReadoutCorner.LL: False, 

619 ReadoutCorner.LR: True, 

620 ReadoutCorner.UL: False, 

621 ReadoutCorner.UR: True} 

622 Y_FLIP = {ReadoutCorner.LL: False, 

623 ReadoutCorner.LR: False, 

624 ReadoutCorner.UL: True, 

625 ReadoutCorner.UR: True} 

626 

627 if X_FLIP[amp.getReadoutCorner()]: 

628 ampData = np.fliplr(ampData) 

629 if Y_FLIP[amp.getReadoutCorner()]: 

630 ampData = np.flipud(ampData) 

631 

632 return ampData 

633 

634 @staticmethod 

635 def local_offset_inverse(inputArr, drift_scale, decay_time, num_previous_pixels=15): 

636 r"""Remove CTI effects from local offsets. 

637 

638 This implements equation 10 of Snyder+21. For an image with 

639 CTI, s'(m, n), the correction factor is equal to the maximum 

640 value of the set of: 

641 

642 .. code-block:: 

643 

644 {A_L s'(m, n - j) exp(-j t / \tau_L)}_j=0^jmax 

645 

646 Parameters 

647 ---------- 

648 inputArr : `numpy.ndarray`, (nx, ny) 

649 Input image data to correct. 

650 drift_scale : `float` 

651 Drift scale (Snyder+21 A_L value) to use in correction. 

652 decay_time : `float` 

653 Decay time (Snyder+21 \tau_L) of the correction. 

654 num_previous_pixels : `int`, optional 

655 Number of previous pixels to use for correction. As the 

656 CTI has an exponential decay, this essentially truncates 

657 the correction where that decay scales the input charge to 

658 near zero. 

659 

660 Returns 

661 ------- 

662 outputArr : `numpy.ndarray`, (nx, ny) 

663 Corrected image data. 

664 """ 

665 r = np.exp(-1/decay_time) 

666 Ny, Nx = inputArr.shape 

667 

668 # j = 0 term: 

669 offset = np.zeros((num_previous_pixels, Ny, Nx)) 

670 offset[0, :, :] = drift_scale*np.maximum(0, inputArr) 

671 

672 # j = 1..jmax terms: 

673 for n in range(1, num_previous_pixels): 

674 offset[n, :, n:] = drift_scale*np.maximum(0, inputArr[:, :-n])*(r**n) 

675 

676 Linv = np.amax(offset, axis=0) 

677 outputArr = inputArr - Linv 

678 

679 return outputArr 

680 

681 @staticmethod 

682 def local_trap_inverse(inputArr, trap, global_cti=0.0, num_previous_pixels=6): 

683 r"""Apply localized trapping inverse operator to pixel signals. 

684 

685 This implements equation 13 of Snyder+21. For an image with 

686 CTI, s'(m, n), the correction factor is equal to the maximum 

687 value of the set of: 

688 

689 .. code-block:: 

690 

691 {A_L s'(m, n - j) exp(-j t / \tau_L)}_j=0^jmax 

692 

693 Parameters 

694 ---------- 

695 inputArr : `numpy.ndarray`, (nx, ny) 

696 Input image data to correct. 

697 trap : `lsst.ip.isr.SerialTrap` 

698 Serial trap describing the capture and release of charge. 

699 global_cti: `float` 

700 Mean charge transfer inefficiency, b from Snyder+21. 

701 num_previous_pixels : `int`, optional 

702 Number of previous pixels to use for correction. 

703 

704 Returns 

705 ------- 

706 outputArr : `numpy.ndarray`, (nx, ny) 

707 Corrected image data. 

708 

709 """ 

710 Ny, Nx = inputArr.shape 

711 a = 1 - global_cti 

712 r = np.exp(-1/trap.emission_time) 

713 

714 # Estimate trap occupancies during readout 

715 trap_occupancy = np.zeros((num_previous_pixels, Ny, Nx)) 

716 for n in range(num_previous_pixels): 

717 trap_occupancy[n, :, n+1:] = trap.capture(np.maximum(0, inputArr))[:, :-(n+1)]*(r**n) 

718 trap_occupancy = np.amax(trap_occupancy, axis=0) 

719 

720 # Estimate captured charge 

721 C = trap.capture(np.maximum(0, inputArr)) - trap_occupancy*r 

722 C[C < 0] = 0. 

723 

724 # Estimate released charge 

725 R = np.zeros(inputArr.shape) 

726 R[:, 1:] = trap_occupancy[:, 1:]*(1-r) 

727 T = R - C 

728 

729 outputArr = inputArr - a*T 

730 

731 return outputArr