Coverage for python/lsst/cp/pipe/deferredCharge.py: 12%

333 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-19 05:37 -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 ) 

31 

32import copy 

33import numpy as np 

34 

35import lsst.pipe.base as pipeBase 

36import lsst.pipe.base.connectionTypes as cT 

37import lsst.pex.config as pexConfig 

38 

39from lsst.ip.isr import DeferredChargeCalib, SerialTrap 

40from lmfit import Minimizer, Parameters 

41 

42from ._lookupStaticCalibration import lookupStaticCalibration 

43 

44 

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 ) 

62 

63 outputCalib = cT.Output( 

64 name="cpCtiCalib", 

65 doc="Output CTI calibration.", 

66 storageClass="IsrCalib", 

67 dimensions=("instrument", "detector"), 

68 isCalibration=True, 

69 ) 

70 

71 

72class CpCtiSolveConfig(pipeBase.PipelineTaskConfig, 

73 pipelineConnections=CpCtiSolveConnections): 

74 """Configuration for the CTI combination. 

75 """ 

76 maxImageMean = pexConfig.Field( 

77 dtype=float, 

78 default=150000.0, 

79 doc="Upper limit on acceptable image flux mean.", 

80 ) 

81 localOffsetColumnRange = pexConfig.ListField( 

82 dtype=int, 

83 default=[3, 13], 

84 doc="First and last overscan column to use for local offset effect.", 

85 ) 

86 

87 maxSignalForCti = pexConfig.Field( 

88 dtype=float, 

89 default=10000.0, 

90 doc="Upper flux limit to use for CTI fit.", 

91 ) 

92 globalCtiColumnRange = pexConfig.ListField( 

93 dtype=int, 

94 default=[1, 2], 

95 doc="First and last overscan column to use for global CTI fit.", 

96 ) 

97 

98 trapColumnRange = pexConfig.ListField( 

99 dtype=int, 

100 default=[1, 20], 

101 doc="First and last overscan column to use for serial trap fit.", 

102 ) 

103 

104 fitError = pexConfig.Field( 

105 # This gives the error on the mean in a given column, and so 

106 # is expected to be $RN / sqrt(N_rows)$. 

107 dtype=float, 

108 default=7.0/np.sqrt(2000), 

109 doc="Error to use during parameter fitting.", 

110 ) 

111 

112 

113class CpCtiSolveTask(pipeBase.PipelineTask): 

114 """Combine CTI measurements to a final calibration. 

115 

116 This task uses the extended pixel edge response (EPER) method as 

117 described by Snyder et al. 2021, Journal of Astronimcal 

118 Telescopes, Instruments, and Systems, 7, 

119 048002. doi:10.1117/1.JATIS.7.4.048002 

120 """ 

121 

122 ConfigClass = CpCtiSolveConfig 

123 _DefaultName = 'cpCtiSolve' 

124 

125 def __init__(self, **kwargs): 

126 super().__init__(**kwargs) 

127 self.allowDebug = True 

128 

129 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

130 inputs = butlerQC.get(inputRefs) 

131 

132 dimensions = [exp.dataId.byName() for exp in inputRefs.inputMeasurements] 

133 inputs['inputDims'] = dimensions 

134 

135 outputs = self.run(**inputs) 

136 butlerQC.put(outputs, outputRefs) 

137 

138 def run(self, inputMeasurements, camera, inputDims): 

139 """Solve for charge transfer inefficiency from overscan measurements. 

140 

141 Parameters 

142 ---------- 

143 inputMeasurements : `list` [`dict`] 

144 List of overscan measurements from each input exposure. 

145 Each dictionary is nested within a top level 'CTI' key, 

146 with measurements organized by amplifier name, containing 

147 keys: 

148 

149 ``"FIRST_MEAN"`` 

150 Mean value of first image column (`float`). 

151 ``"LAST_MEAN"`` 

152 Mean value of last image column (`float`). 

153 ``"IMAGE_MEAN"`` 

154 Mean value of the entire image region (`float`). 

155 ``"OVERSCAN_COLUMNS"`` 

156 List of overscan column indicies (`list` [`int`]). 

157 ``"OVERSCAN_VALUES"`` 

158 List of overscan column means (`list` [`float`]). 

159 camera : `lsst.afw.cameraGeom.Camera` 

160 Camera geometry to use to find detectors. 

161 inputDims : `list` [`dict`] 

162 List of input dimensions from each input exposure. 

163 

164 Returns 

165 ------- 

166 results : `lsst.pipe.base.Struct` 

167 Result struct containing: 

168 

169 ``outputCalib`` 

170 Final CTI calibration data 

171 (`lsst.ip.isr.DeferredChargeCalib`). 

172 

173 Raises 

174 ------ 

175 RuntimeError 

176 Raised if data from multiple detectors are passed in. 

177 """ 

178 detectorSet = set([d['detector'] for d in inputDims]) 

179 if len(detectorSet) != 1: 

180 raise RuntimeError("Inputs for too many detectors passed.") 

181 detectorId = detectorSet.pop() 

182 detector = camera[detectorId] 

183 

184 # Initialize with detector. 

185 calib = DeferredChargeCalib(camera=camera, detector=detector) 

186 

187 localCalib = self.solveLocalOffsets(inputMeasurements, calib, detector) 

188 

189 globalCalib = self.solveGlobalCti(inputMeasurements, localCalib, detector) 

190 

191 finalCalib = self.findTraps(inputMeasurements, globalCalib, detector) 

192 

193 return pipeBase.Struct( 

194 outputCalib=finalCalib, 

195 ) 

196 

197 def solveLocalOffsets(self, inputMeasurements, calib, detector): 

198 """Solve for local (pixel-to-pixel) electronic offsets. 

199 

200 This method fits for \tau_L, the local electronic offset decay 

201 time constant, and A_L, the local electronic offset constant 

202 of proportionality. 

203 

204 Parameters 

205 ---------- 

206 inputMeasurements : `list` [`dict`] 

207 List of overscan measurements from each input exposure. 

208 Each dictionary is nested within a top level 'CTI' key, 

209 with measurements organized by amplifier name, containing 

210 keys: 

211 

212 ``"FIRST_MEAN"`` 

213 Mean value of first image column (`float`). 

214 ``"LAST_MEAN"`` 

215 Mean value of last image column (`float`). 

216 ``"IMAGE_MEAN"`` 

217 Mean value of the entire image region (`float`). 

218 ``"OVERSCAN_COLUMNS"`` 

219 List of overscan column indicies (`list` [`int`]). 

220 ``"OVERSCAN_VALUES"`` 

221 List of overscan column means (`list` [`float`]). 

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

223 Calibration to populate with values. 

224 detector : `lsst.afw.cameraGeom.Detector` 

225 Detector object containing the geometry information for 

226 the amplifiers. 

227 

228 Returns 

229 ------- 

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

231 Populated calibration. 

232 

233 Raises 

234 ------ 

235 RuntimeError 

236 Raised if no data remains after flux filtering. 

237 

238 Notes 

239 ----- 

240 The original CTISIM code (https://github.com/Snyder005/ctisim) 

241 uses a data model in which the "overscan" consists of the 

242 standard serial overscan bbox with the values for the last 

243 imaging data column prepended to that list. This version of 

244 the code keeps the overscan and imaging sections separate, and 

245 so a -1 offset is needed to ensure that the same columns are 

246 used for fitting between this code and CTISIM. This offset 

247 removes that last imaging data column from the count. 

248 """ 

249 # Range to fit. These are in "camera" coordinates, and so 

250 # need to have the count for last image column removed. 

251 start, stop = self.config.localOffsetColumnRange 

252 start -= 1 

253 stop -= 1 

254 

255 # Loop over amps/inputs, fitting those columns from 

256 # "non-saturated" inputs. 

257 for amp in detector.getAmplifiers(): 

258 ampName = amp.getName() 

259 

260 # Number of serial shifts. 

261 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth() 

262 

263 # The signal is the mean intensity of each input, and the 

264 # data are the overscan columns to fit. For detectors 

265 # with non-zero CTI, the charge from the imaging region 

266 # leaks into the overscan region. 

267 signal = [] 

268 data = [] 

269 Nskipped = 0 

270 for exposureEntry in inputMeasurements: 

271 exposureDict = exposureEntry['CTI'] 

272 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean: 

273 signal.append(exposureDict[ampName]['IMAGE_MEAN']) 

274 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1]) 

275 else: 

276 Nskipped += 1 

277 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxImageMean}.") 

278 if len(signal) == 0 or len(data) == 0: 

279 raise RuntimeError("All exposures brighter than config.maxImageMean and excluded.") 

280 

281 signal = np.array(signal) 

282 data = np.array(data) 

283 

284 ind = signal.argsort() 

285 signal = signal[ind] 

286 data = data[ind] 

287 

288 params = Parameters() 

289 params.add('ctiexp', value=-6, min=-7, max=-5, vary=False) 

290 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False) 

291 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False) 

292 params.add('emissiontime', value=0.4, min=0.1, max=1.0, vary=False) 

293 params.add('driftscale', value=0.00022, min=0., max=0.001, vary=True) 

294 params.add('decaytime', value=2.4, min=0.1, max=4.0, vary=True) 

295 

296 model = SimpleModel() 

297 minner = Minimizer(model.difference, params, 

298 fcn_args=(signal, data, self.config.fitError, nCols), 

299 fcn_kws={'start': start, 'stop': stop}) 

300 result = minner.minimize() 

301 

302 # Save results for the drift scale and decay time. 

303 if not result.success: 

304 self.log.warning("Electronics fitting failure for amplifier %s.", ampName) 

305 

306 calib.globalCti[ampName] = 10**result.params['ctiexp'] 

307 calib.driftScale[ampName] = result.params['driftscale'].value if result.success else 0.0 

308 calib.decayTime[ampName] = result.params['decaytime'].value if result.success else 2.4 

309 self.log.info("CTI Local Fit %s: cti: %g decayTime: %g driftScale %g", 

310 ampName, calib.globalCti[ampName], calib.decayTime[ampName], 

311 calib.driftScale[ampName]) 

312 return calib 

313 

314 def solveGlobalCti(self, inputMeasurements, calib, detector): 

315 """Solve for global CTI constant. 

316 

317 This method solves for the mean global CTI, b. 

318 

319 Parameters 

320 ---------- 

321 inputMeasurements : `list` [`dict`] 

322 List of overscan measurements from each input exposure. 

323 Each dictionary is nested within a top level 'CTI' key, 

324 with measurements organized by amplifier name, containing 

325 keys: 

326 

327 ``"FIRST_MEAN"`` 

328 Mean value of first image column (`float`). 

329 ``"LAST_MEAN"`` 

330 Mean value of last image column (`float`). 

331 ``"IMAGE_MEAN"`` 

332 Mean value of the entire image region (`float`). 

333 ``"OVERSCAN_COLUMNS"`` 

334 List of overscan column indicies (`list` [`int`]). 

335 ``"OVERSCAN_VALUES"`` 

336 List of overscan column means (`list` [`float`]). 

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

338 Calibration to populate with values. 

339 detector : `lsst.afw.cameraGeom.Detector` 

340 Detector object containing the geometry information for 

341 the amplifiers. 

342 

343 Returns 

344 ------- 

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

346 Populated calibration. 

347 

348 Raises 

349 ------ 

350 RuntimeError 

351 Raised if no data remains after flux filtering. 

352 

353 Notes 

354 ----- 

355 The original CTISIM code uses a data model in which the 

356 "overscan" consists of the standard serial overscan bbox with 

357 the values for the last imaging data column prepended to that 

358 list. This version of the code keeps the overscan and imaging 

359 sections separate, and so a -1 offset is needed to ensure that 

360 the same columns are used for fitting between this code and 

361 CTISIM. This offset removes that last imaging data column 

362 from the count. 

363 """ 

364 # Range to fit. These are in "camera" coordinates, and so 

365 # need to have the count for last image column removed. 

366 start, stop = self.config.globalCtiColumnRange 

367 start -= 1 

368 stop -= 1 

369 

370 # Loop over amps/inputs, fitting those columns from 

371 # "non-saturated" inputs. 

372 for amp in detector.getAmplifiers(): 

373 ampName = amp.getName() 

374 

375 # Number of serial shifts. 

376 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth() 

377 

378 # The signal is the mean intensity of each input, and the 

379 # data are the overscan columns to fit. For detectors 

380 # with non-zero CTI, the charge from the imaging region 

381 # leaks into the overscan region. 

382 signal = [] 

383 data = [] 

384 Nskipped = 0 

385 for exposureEntry in inputMeasurements: 

386 exposureDict = exposureEntry['CTI'] 

387 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxSignalForCti: 

388 signal.append(exposureDict[ampName]['IMAGE_MEAN']) 

389 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1]) 

390 else: 

391 Nskipped += 1 

392 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxSignalForCti}.") 

393 if len(signal) == 0 or len(data) == 0: 

394 raise RuntimeError("All exposures brighter than config.maxSignalForCti and excluded.") 

395 

396 signal = np.array(signal) 

397 data = np.array(data) 

398 

399 ind = signal.argsort() 

400 signal = signal[ind] 

401 data = data[ind] 

402 

403 # CTI test. This looks at the charge that has leaked into 

404 # the first few columns of the overscan. 

405 overscan1 = data[:, 0] 

406 overscan2 = data[:, 1] 

407 test = (np.array(overscan1) + np.array(overscan2))/(nCols*np.array(signal)) 

408 testResult = np.median(test) > 5.E-6 

409 self.log.info("Estimate of CTI test is %f for amp %s, %s.", np.median(test), ampName, 

410 "full fitting will be performed" if testResult else 

411 "only global CTI fitting will be performed") 

412 

413 self.debugView(ampName, signal, test) 

414 

415 params = Parameters() 

416 params.add('ctiexp', value=-6, min=-7, max=-5, vary=True) 

417 params.add('trapsize', value=5.0 if testResult else 0.0, min=0.0, max=30., 

418 vary=True if testResult else False) 

419 params.add('scaling', value=0.08, min=0.0, max=1.0, 

420 vary=True if testResult else False) 

421 params.add('emissiontime', value=0.35, min=0.1, max=1.0, 

422 vary=True if testResult else False) 

423 params.add('driftscale', value=calib.driftScale[ampName], min=0., max=0.001, vary=False) 

424 params.add('decaytime', value=calib.decayTime[ampName], min=0.1, max=4.0, vary=False) 

425 

426 model = SimulatedModel() 

427 minner = Minimizer(model.difference, params, 

428 fcn_args=(signal, data, self.config.fitError, nCols, amp), 

429 fcn_kws={'start': start, 'stop': stop, 'trap_type': 'linear'}) 

430 result = minner.minimize() 

431 

432 # Only the global CTI term is retained from this fit. 

433 calib.globalCti[ampName] = 10**result.params['ctiexp'].value 

434 self.log.info("CTI Global Cti %s: cti: %g decayTime: %g driftScale %g", 

435 ampName, calib.globalCti[ampName], calib.decayTime[ampName], 

436 calib.driftScale[ampName]) 

437 

438 return calib 

439 

440 def debugView(self, ampName, signal, test): 

441 """Debug method for global CTI test value. 

442 

443 Parameters 

444 ---------- 

445 ampName : `str` 

446 Name of the amp for plot title. 

447 signal : `list` [`float`] 

448 Image means for the input exposures. 

449 test : `list` [`float`] 

450 CTI test value to plot. 

451 """ 

452 import lsstDebug 

453 if not lsstDebug.Info(__name__).display: 

454 return 

455 if not self.allowDebug: 

456 return 

457 

458 import matplotlib.pyplot as plot 

459 figure = plot.figure(1) 

460 figure.clear() 

461 plot.xscale('log', base=10.0) 

462 plot.yscale('log', base=10.0) 

463 plot.xlabel('Flat Field Signal [e-?]') 

464 plot.ylabel('Serial CTI') 

465 plot.title(ampName) 

466 plot.plot(signal, test) 

467 

468 figure.show() 

469 prompt = "Press Enter or c to continue [chp]..." 

470 while True: 

471 ans = input(prompt).lower() 

472 if ans in ("", " ", "c",): 

473 break 

474 elif ans in ("p", ): 

475 import pdb 

476 pdb.set_trace() 

477 elif ans in ('x', ): 

478 self.allowDebug = False 

479 break 

480 elif ans in ("h", ): 

481 print("[h]elp [c]ontinue [p]db e[x]itDebug") 

482 plot.close() 

483 

484 def findTraps(self, inputMeasurements, calib, detector): 

485 """Solve for serial trap parameters. 

486 

487 Parameters 

488 ---------- 

489 inputMeasurements : `list` [`dict`] 

490 List of overscan measurements from each input exposure. 

491 Each dictionary is nested within a top level 'CTI' key, 

492 with measurements organized by amplifier name, containing 

493 keys: 

494 

495 ``"FIRST_MEAN"`` 

496 Mean value of first image column (`float`). 

497 ``"LAST_MEAN"`` 

498 Mean value of last image column (`float`). 

499 ``"IMAGE_MEAN"`` 

500 Mean value of the entire image region (`float`). 

501 ``"OVERSCAN_COLUMNS"`` 

502 List of overscan column indicies (`list` [`int`]). 

503 ``"OVERSCAN_VALUES"`` 

504 List of overscan column means (`list` [`float`]). 

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

506 Calibration to populate with values. 

507 detector : `lsst.afw.cameraGeom.Detector` 

508 Detector object containing the geometry information for 

509 the amplifiers. 

510 

511 Returns 

512 ------- 

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

514 Populated calibration. 

515 

516 Raises 

517 ------ 

518 RuntimeError 

519 Raised if no data remains after flux filtering. 

520 

521 Notes 

522 ----- 

523 The original CTISIM code uses a data model in which the 

524 "overscan" consists of the standard serial overscan bbox with 

525 the values for the last imaging data column prepended to that 

526 list. This version of the code keeps the overscan and imaging 

527 sections separate, and so a -1 offset is needed to ensure that 

528 the same columns are used for fitting between this code and 

529 CTISIM. This offset removes that last imaging data column 

530 from the count. 

531 """ 

532 # Range to fit. These are in "camera" coordinates, and so 

533 # need to have the count for last image column removed. 

534 start, stop = self.config.trapColumnRange 

535 start -= 1 

536 stop -= 1 

537 

538 # Loop over amps/inputs, fitting those columns from 

539 # "non-saturated" inputs. 

540 for amp in detector.getAmplifiers(): 

541 ampName = amp.getName() 

542 

543 # Number of serial shifts. 

544 nCols = amp.getRawDataBBox().getWidth() + amp.getRawSerialPrescanBBox().getWidth() 

545 

546 # The signal is the mean intensity of each input, and the 

547 # data are the overscan columns to fit. The new_signal is 

548 # the mean in the last image column. Any serial trap will 

549 # take charge from this column, and deposit it into the 

550 # overscan columns. 

551 signal = [] 

552 data = [] 

553 new_signal = [] 

554 Nskipped = 0 

555 for exposureEntry in inputMeasurements: 

556 exposureDict = exposureEntry['CTI'] 

557 if exposureDict[ampName]['IMAGE_MEAN'] < self.config.maxImageMean: 

558 signal.append(exposureDict[ampName]['IMAGE_MEAN']) 

559 data.append(exposureDict[ampName]['OVERSCAN_VALUES'][start:stop+1]) 

560 new_signal.append(exposureDict[ampName]['LAST_MEAN']) 

561 else: 

562 Nskipped += 1 

563 self.log.info(f"Skipped {Nskipped} exposures brighter than {self.config.maxImageMean}.") 

564 if len(signal) == 0 or len(data) == 0: 

565 raise RuntimeError("All exposures brighter than config.maxImageMean and excluded.") 

566 

567 signal = np.array(signal) 

568 data = np.array(data) 

569 new_signal = np.array(new_signal) 

570 

571 ind = signal.argsort() 

572 signal = signal[ind] 

573 data = data[ind] 

574 new_signal = new_signal[ind] 

575 

576 # In the absense of any trap, the model results using the 

577 # parameters already determined will match the observed 

578 # overscan results. 

579 params = Parameters() 

580 params.add('ctiexp', value=np.log10(calib.globalCti[ampName]), 

581 min=-7, max=-5, vary=False) 

582 params.add('trapsize', value=0.0, min=0.0, max=10., vary=False) 

583 params.add('scaling', value=0.08, min=0.0, max=1.0, vary=False) 

584 params.add('emissiontime', value=0.35, min=0.1, max=1.0, vary=False) 

585 params.add('driftscale', value=calib.driftScale[ampName], 

586 min=0.0, max=0.001, vary=False) 

587 params.add('decaytime', value=calib.decayTime[ampName], 

588 min=0.1, max=4.0, vary=False) 

589 

590 model = SimpleModel.model_results(params, signal, nCols, 

591 start=start, stop=stop) 

592 

593 # Evaluating trap: the difference between the model and 

594 # observed data. 

595 res = np.sum((data-model)[:, :3], axis=1) 

596 

597 # Create spline model for the trap, using the residual 

598 # between data and model as a function of the last image 

599 # column mean (new_signal) scaled by (1 - A_L). 

600 new_signal = np.asarray((1 - calib.driftScale[ampName])*new_signal, dtype=np.float64) 

601 x = new_signal 

602 y = np.maximum(0, res) 

603 

604 # Pad left with ramp 

605 y = np.pad(y, (10, 0), 'linear_ramp', end_values=(0, 0)) 

606 x = np.pad(x, (10, 0), 'linear_ramp', end_values=(0, 0)) 

607 

608 # Pad right with constant 

609 y = np.pad(y, (1, 1), 'constant', constant_values=(0, y[-1])) 

610 x = np.pad(x, (1, 1), 'constant', constant_values=(-1, 200000.)) 

611 

612 trap = SerialTrap(20000.0, 0.4, 1, 'spline', np.concatenate((x, y)).tolist()) 

613 calib.serialTraps[ampName] = trap 

614 

615 return calib 

616 

617 

618class OverscanModel: 

619 """Base class for handling model/data fit comparisons. 

620 

621 This handles all of the methods needed for the lmfit Minimizer to 

622 run. 

623 """ 

624 

625 @staticmethod 

626 def model_results(params, signal, num_transfers, start=1, stop=10): 

627 """Generate a realization of the overscan model, using the specified 

628 fit parameters and input signal. 

629 

630 Parameters 

631 ---------- 

632 params : `lmfit.Parameters` 

633 Object containing the model parameters. 

634 signal : `np.ndarray`, (nMeasurements) 

635 Array of image means. 

636 num_transfers : `int` 

637 Number of serial transfers that the charge undergoes. 

638 start : `int`, optional 

639 First overscan column to fit. This number includes the 

640 last imaging column, and needs to be adjusted by one when 

641 using the overscan bounding box. 

642 stop : `int`, optional 

643 Last overscan column to fit. This number includes the 

644 last imaging column, and needs to be adjusted by one when 

645 using the overscan bounding box. 

646 

647 Returns 

648 ------- 

649 results : `np.ndarray`, (nMeasurements, nCols) 

650 Model results. 

651 """ 

652 raise NotImplementedError("Subclasses must implement the model calculation.") 

653 

654 def loglikelihood(self, params, signal, data, error, *args, **kwargs): 

655 """Calculate log likelihood of the model. 

656 

657 Parameters 

658 ---------- 

659 params : `lmfit.Parameters` 

660 Object containing the model parameters. 

661 signal : `np.ndarray`, (nMeasurements) 

662 Array of image means. 

663 data : `np.ndarray`, (nMeasurements, nCols) 

664 Array of overscan column means from each measurement. 

665 error : `float` 

666 Fixed error value. 

667 *args : 

668 Additional position arguments. 

669 **kwargs : 

670 Additional keyword arguments. 

671 

672 Returns 

673 ------- 

674 logL : `float` 

675 The log-likelihood of the observed data given the model 

676 parameters. 

677 """ 

678 model_results = self.model_results(params, signal, *args, **kwargs) 

679 

680 inv_sigma2 = 1.0/(error**2.0) 

681 diff = model_results - data 

682 

683 return -0.5*(np.sum(inv_sigma2*(diff)**2.)) 

684 

685 def negative_loglikelihood(self, params, signal, data, error, *args, **kwargs): 

686 """Calculate negative log likelihood of the model. 

687 

688 Parameters 

689 ---------- 

690 params : `lmfit.Parameters` 

691 Object containing the model parameters. 

692 signal : `np.ndarray`, (nMeasurements) 

693 Array of image means. 

694 data : `np.ndarray`, (nMeasurements, nCols) 

695 Array of overscan column means from each measurement. 

696 error : `float` 

697 Fixed error value. 

698 *args : 

699 Additional position arguments. 

700 **kwargs : 

701 Additional keyword arguments. 

702 

703 Returns 

704 ------- 

705 negativelogL : `float` 

706 The negative log-likelihood of the observed data given the 

707 model parameters. 

708 """ 

709 ll = self.loglikelihood(params, signal, data, error, *args, **kwargs) 

710 

711 return -ll 

712 

713 def rms_error(self, params, signal, data, error, *args, **kwargs): 

714 """Calculate RMS error between model and data. 

715 

716 Parameters 

717 ---------- 

718 params : `lmfit.Parameters` 

719 Object containing the model parameters. 

720 signal : `np.ndarray`, (nMeasurements) 

721 Array of image means. 

722 data : `np.ndarray`, (nMeasurements, nCols) 

723 Array of overscan column means from each measurement. 

724 error : `float` 

725 Fixed error value. 

726 *args : 

727 Additional position arguments. 

728 **kwargs : 

729 Additional keyword arguments. 

730 

731 Returns 

732 ------- 

733 rms : `float` 

734 The rms error between the model and input data. 

735 """ 

736 model_results = self.model_results(params, signal, *args, **kwargs) 

737 

738 diff = model_results - data 

739 rms = np.sqrt(np.mean(np.square(diff))) 

740 

741 return rms 

742 

743 def difference(self, params, signal, data, error, *args, **kwargs): 

744 """Calculate the flattened difference array between model and data. 

745 

746 Parameters 

747 ---------- 

748 params : `lmfit.Parameters` 

749 Object containing the model parameters. 

750 signal : `np.ndarray`, (nMeasurements) 

751 Array of image means. 

752 data : `np.ndarray`, (nMeasurements, nCols) 

753 Array of overscan column means from each measurement. 

754 error : `float` 

755 Fixed error value. 

756 *args : 

757 Additional position arguments. 

758 **kwargs : 

759 Additional keyword arguments. 

760 

761 Returns 

762 ------- 

763 difference : `np.ndarray`, (nMeasurements*nCols) 

764 The rms error between the model and input data. 

765 """ 

766 model_results = self.model_results(params, signal, *args, **kwargs) 

767 diff = (model_results-data).flatten() 

768 

769 return diff 

770 

771 

772class SimpleModel(OverscanModel): 

773 """Simple analytic overscan model.""" 

774 

775 @staticmethod 

776 def model_results(params, signal, num_transfers, start=1, stop=10): 

777 """Generate a realization of the overscan model, using the specified 

778 fit parameters and input signal. 

779 

780 Parameters 

781 ---------- 

782 params : `lmfit.Parameters` 

783 Object containing the model parameters. 

784 signal : `np.ndarray`, (nMeasurements) 

785 Array of image means. 

786 num_transfers : `int` 

787 Number of serial transfers that the charge undergoes. 

788 start : `int`, optional 

789 First overscan column to fit. This number includes the 

790 last imaging column, and needs to be adjusted by one when 

791 using the overscan bounding box. 

792 stop : `int`, optional 

793 Last overscan column to fit. This number includes the 

794 last imaging column, and needs to be adjusted by one when 

795 using the overscan bounding box. 

796 

797 Returns 

798 ------- 

799 res : `np.ndarray`, (nMeasurements, nCols) 

800 Model results. 

801 """ 

802 v = params.valuesdict() 

803 v['cti'] = 10**v['ctiexp'] 

804 

805 # Adjust column numbering to match DM overscan bbox. 

806 start += 1 

807 stop += 1 

808 

809 x = np.arange(start, stop+1) 

810 res = np.zeros((signal.shape[0], x.shape[0])) 

811 

812 for i, s in enumerate(signal): 

813 # This is largely equivalent to equation 2. The minimum 

814 # indicates that a trap cannot emit more charge than is 

815 # available, nor can it emit more charge than it can hold. 

816 # This scales the exponential release of charge from the 

817 # trap. The next term defines the contribution from the 

818 # global CTI at each pixel transfer, and the final term 

819 # includes the contribution from local CTI effects. 

820 res[i, :] = (np.minimum(v['trapsize'], s*v['scaling']) 

821 * (np.exp(1/v['emissiontime']) - 1.0) 

822 * np.exp(-x/v['emissiontime']) 

823 + s*num_transfers*v['cti']**x 

824 + v['driftscale']*s*np.exp(-x/float(v['decaytime']))) 

825 

826 return res 

827 

828 

829class SimulatedModel(OverscanModel): 

830 """Simulated overscan model.""" 

831 

832 @staticmethod 

833 def model_results(params, signal, num_transfers, amp, start=1, stop=10, trap_type=None): 

834 """Generate a realization of the overscan model, using the specified 

835 fit parameters and input signal. 

836 

837 Parameters 

838 ---------- 

839 params : `lmfit.Parameters` 

840 Object containing the model parameters. 

841 signal : `np.ndarray`, (nMeasurements) 

842 Array of image means. 

843 num_transfers : `int` 

844 Number of serial transfers that the charge undergoes. 

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

846 Amplifier to use for geometry information. 

847 start : `int`, optional 

848 First overscan column to fit. This number includes the 

849 last imaging column, and needs to be adjusted by one when 

850 using the overscan bounding box. 

851 stop : `int`, optional 

852 Last overscan column to fit. This number includes the 

853 last imaging column, and needs to be adjusted by one when 

854 using the overscan bounding box. 

855 trap_type : `str`, optional 

856 Type of trap model to use. 

857 

858 Returns 

859 ------- 

860 results : `np.ndarray`, (nMeasurements, nCols) 

861 Model results. 

862 """ 

863 v = params.valuesdict() 

864 

865 # Adjust column numbering to match DM overscan bbox. 

866 start += 1 

867 stop += 1 

868 

869 # Electronics effect optimization 

870 output_amplifier = FloatingOutputAmplifier(1.0, v['driftscale'], v['decaytime']) 

871 

872 # CTI optimization 

873 v['cti'] = 10**v['ctiexp'] 

874 

875 # Trap type for optimization 

876 if trap_type is None: 

877 trap = None 

878 elif trap_type == 'linear': 

879 trap = SerialTrap(v['trapsize'], v['emissiontime'], 1, 'linear', 

880 [v['scaling']]) 

881 elif trap_type == 'logistic': 

882 trap = SerialTrap(v['trapsize'], v['emissiontime'], 1, 'logistic', 

883 [v['f0'], v['k']]) 

884 else: 

885 raise ValueError('Trap type must be linear or logistic or None') 

886 

887 # Simulate ramp readout 

888 imarr = np.zeros((signal.shape[0], amp.getRawDataBBox().getWidth())) 

889 ramp = SegmentSimulator(imarr, amp.getRawSerialPrescanBBox().getWidth(), output_amplifier, 

890 cti=v['cti'], traps=trap) 

891 ramp.ramp_exp(signal) 

892 model_results = ramp.readout(serial_overscan_width=amp.getRawSerialOverscanBBox().getWidth(), 

893 parallel_overscan_width=0) 

894 

895 ncols = amp.getRawSerialPrescanBBox().getWidth() + amp.getRawDataBBox().getWidth() 

896 

897 return model_results[:, ncols+start-1:ncols+stop] 

898 

899 

900class SegmentSimulator: 

901 """Controls the creation of simulated segment images. 

902 

903 Parameters 

904 ---------- 

905 imarr : `np.ndarray` (nx, ny) 

906 Image data array. 

907 prescan_width : `int` 

908 Number of serial prescan columns. 

909 output_amplifier : `lsst.cp.pipe.FloatingOutputAmplifier` 

910 An object holding the gain, read noise, and global_offset. 

911 cti : `float` 

912 Global CTI value. 

913 traps : `list` [`lsst.ip.isr.SerialTrap`] 

914 Serial traps to simulate. 

915 """ 

916 

917 def __init__(self, imarr, prescan_width, output_amplifier, cti=0.0, traps=None): 

918 # Image array geometry 

919 self.prescan_width = prescan_width 

920 self.ny, self.nx = imarr.shape 

921 

922 self.segarr = np.zeros((self.ny, self.nx+prescan_width)) 

923 self.segarr[:, prescan_width:] = imarr 

924 

925 # Serial readout information 

926 self.output_amplifier = output_amplifier 

927 if isinstance(cti, np.ndarray): 

928 raise ValueError("cti must be single value, not an array.") 

929 self.cti = cti 

930 

931 self.serial_traps = None 

932 self.do_trapping = False 

933 if traps is not None: 

934 if not isinstance(traps, list): 

935 traps = [traps] 

936 for trap in traps: 

937 self.add_trap(trap) 

938 

939 def add_trap(self, serial_trap): 

940 """Add a trap to the serial register. 

941 

942 Parameters 

943 ---------- 

944 serial_trap : `lsst.ip.isr.SerialTrap` 

945 The trap to add. 

946 """ 

947 try: 

948 self.serial_traps.append(serial_trap) 

949 except AttributeError: 

950 self.serial_traps = [serial_trap] 

951 self.do_trapping = True 

952 

953 def ramp_exp(self, signal_list): 

954 """Simulate an image with varying flux illumination per row. 

955 

956 This method simulates a segment image where the signal level 

957 increases along the horizontal direction, according to the 

958 provided list of signal levels. 

959 

960 Parameters 

961 ---------- 

962 signal_list : `list` [`float`] 

963 List of signal levels. 

964 

965 Raises 

966 ------ 

967 ValueError 

968 Raised if the length of the signal list does not equal the 

969 number of rows. 

970 """ 

971 if len(signal_list) != self.ny: 

972 raise ValueError("Signal list does not match row count.") 

973 

974 ramp = np.tile(signal_list, (self.nx, 1)).T 

975 self.segarr[:, self.prescan_width:] += ramp 

976 

977 def readout(self, serial_overscan_width=10, parallel_overscan_width=0): 

978 """Simulate serial readout of the segment image. 

979 

980 This method performs the serial readout of a segment image 

981 given the appropriate SerialRegister object and the properties 

982 of the ReadoutAmplifier. Additional arguments can be provided 

983 to account for the number of desired overscan transfers. The 

984 result is a simulated final segment image, in ADU. 

985 

986 Parameters 

987 ---------- 

988 serial_overscan_width : `int`, optional 

989 Number of serial overscan columns. 

990 parallel_overscan_width : `int`, optional 

991 Number of parallel overscan rows. 

992 

993 Returns 

994 ------- 

995 result : `np.ndarray` (nx, ny) 

996 Simulated image, including serial prescan, serial 

997 overscan, and parallel overscan regions. 

998 """ 

999 # Create output array 

1000 iy = int(self.ny + parallel_overscan_width) 

1001 ix = int(self.nx + self.prescan_width + serial_overscan_width) 

1002 image = np.random.normal(loc=self.output_amplifier.global_offset, 

1003 scale=self.output_amplifier.noise, 

1004 size=(iy, ix)) 

1005 free_charge = copy.deepcopy(self.segarr) 

1006 

1007 # Set flow control parameters 

1008 do_trapping = self.do_trapping 

1009 cti = self.cti 

1010 

1011 offset = np.zeros(self.ny) 

1012 cte = 1 - cti 

1013 if do_trapping: 

1014 for trap in self.serial_traps: 

1015 trap.initialize(self.ny, self.nx, self.prescan_width) 

1016 

1017 for i in range(ix): 

1018 # Trap capture 

1019 if do_trapping: 

1020 for trap in self.serial_traps: 

1021 captured_charge = trap.trap_charge(free_charge) 

1022 free_charge -= captured_charge 

1023 

1024 # Pixel-to-pixel proportional loss 

1025 transferred_charge = free_charge*cte 

1026 deferred_charge = free_charge*cti 

1027 

1028 # Pixel transfer and readout 

1029 offset = self.output_amplifier.local_offset(offset, 

1030 transferred_charge[:, 0]) 

1031 image[:iy-parallel_overscan_width, i] += transferred_charge[:, 0] + offset 

1032 

1033 free_charge = np.pad(transferred_charge, ((0, 0), (0, 1)), 

1034 mode='constant')[:, 1:] + deferred_charge 

1035 

1036 # Trap emission 

1037 if do_trapping: 

1038 for trap in self.serial_traps: 

1039 released_charge = trap.release_charge() 

1040 free_charge += released_charge 

1041 

1042 return image/float(self.output_amplifier.gain) 

1043 

1044 

1045class FloatingOutputAmplifier: 

1046 """Object representing the readout amplifier of a single channel. 

1047 

1048 Parameters 

1049 ---------- 

1050 gain : `float` 

1051 Amplifier gain. 

1052 scale : `float` 

1053 Drift scale for the amplifier. 

1054 decay_time : `float` 

1055 Decay time for the bias drift. 

1056 noise : `float`, optional 

1057 Amplifier read noise. 

1058 offset : `float`, optional 

1059 Global CTI offset. 

1060 """ 

1061 

1062 def __init__(self, gain, scale, decay_time, noise=0.0, offset=0.0): 

1063 

1064 self.gain = gain 

1065 self.noise = noise 

1066 self.global_offset = offset 

1067 

1068 self.update_parameters(scale, decay_time) 

1069 

1070 def local_offset(self, old, signal): 

1071 """Calculate local offset hysteresis. 

1072 

1073 Parameters 

1074 ---------- 

1075 old : `np.ndarray`, (,) 

1076 Previous iteration. 

1077 signal : `np.ndarray`, (,) 

1078 Current column measurements. 

1079 

1080 Returns 

1081 ------- 

1082 offset : `np.ndarray` 

1083 Local offset. 

1084 """ 

1085 new = self.scale*signal 

1086 

1087 return np.maximum(new, old*np.exp(-1/self.decay_time)) 

1088 

1089 def update_parameters(self, scale, decay_time): 

1090 """Update parameter values, if within acceptable values. 

1091 

1092 Parameters 

1093 ---------- 

1094 scale : `float` 

1095 Drift scale for the amplifier. 

1096 decay_time : `float` 

1097 Decay time for the bias drift. 

1098 

1099 Raises 

1100 ------ 

1101 ValueError 

1102 Raised if the input parameters are out of range. 

1103 """ 

1104 if scale < 0.0: 

1105 raise ValueError("Scale must be greater than or equal to 0.") 

1106 if np.isnan(scale): 

1107 raise ValueError("Scale must be real-valued number, not NaN.") 

1108 self.scale = scale 

1109 if decay_time <= 0.0: 

1110 raise ValueError("Decay time must be greater than 0.") 

1111 if np.isnan(decay_time): 

1112 raise ValueError("Decay time must be real-valued number, not NaN.") 

1113 self.decay_time = decay_time