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

327 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-07-29 03:12 -0700

1# This file is part of cp_pipe. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21# 

22import copy 

23import numpy as np 

24 

25import lsst.pipe.base as pipeBase 

26import lsst.pipe.base.connectionTypes as cT 

27import lsst.pex.config as pexConfig 

28 

29from lsst.ip.isr import DeferredChargeCalib, SerialTrap 

30from lmfit import Minimizer, Parameters 

31 

32from ._lookupStaticCalibration import lookupStaticCalibration 

33 

34__all__ = ('CpCtiSolveConnections', 

35 'CpCtiSolveConfig', 

36 'CpCtiSolveTask', 

37 'OverscanModel', 

38 'SimpleModel', 

39 'SimulatedModel', 

40 'SegmentSimulator', 

41 'FloatingOutputAmplifier', 

42 ) 

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 ) 

69 

70 

71class CpCtiSolveConfig(pipeBase.PipelineTaskConfig, 

72 pipelineConnections=CpCtiSolveConnections): 

73 """Configuration for the CTI combination. 

74 """ 

75 maxImageMean = pexConfig.Field( 

76 dtype=float, 

77 default=150000.0, 

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

79 ) 

80 localOffsetColumnRange = pexConfig.ListField( 

81 dtype=int, 

82 default=[3, 13], 

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

84 ) 

85 

86 maxSignalForCti = pexConfig.Field( 

87 dtype=float, 

88 default=10000.0, 

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

90 ) 

91 globalCtiColumnRange = pexConfig.ListField( 

92 dtype=int, 

93 default=[1, 2], 

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

95 ) 

96 

97 trapColumnRange = pexConfig.ListField( 

98 dtype=int, 

99 default=[1, 20], 

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

101 ) 

102 

103 fitError = pexConfig.Field( 

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

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

106 dtype=float, 

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

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

109 ) 

110 

111 

112class CpCtiSolveTask(pipeBase.PipelineTask, 

113 pipeBase.CmdLineTask): 

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 Notes 

234 ----- 

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

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

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

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

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

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

241 CTISIM. This offset removes that last imaging data column 

242 from the count. 

243 """ 

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

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

246 start, stop = self.config.localOffsetColumnRange 

247 start -= 1 

248 stop -= 1 

249 

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

251 # "non-saturated" inputs. 

252 for amp in detector.getAmplifiers(): 

253 ampName = amp.getName() 

254 

255 # Number of serial shifts. 

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

257 

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

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

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

261 # leaks into the overscan region. 

262 signal = [] 

263 data = [] 

264 Nskipped = 0 

265 for exposureEntry in inputMeasurements: 

266 exposureDict = exposureEntry['CTI'] 

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

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

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

270 else: 

271 Nskipped += 1 

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

273 

274 signal = np.array(signal) 

275 data = np.array(data) 

276 

277 ind = signal.argsort() 

278 signal = signal[ind] 

279 data = data[ind] 

280 

281 params = Parameters() 

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

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

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

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

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

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

288 

289 model = SimpleModel() 

290 minner = Minimizer(model.difference, params, 

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

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

293 result = minner.minimize() 

294 

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

296 if not result.success: 

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

298 

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

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

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

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

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

304 calib.driftScale[ampName]) 

305 return calib 

306 

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

308 """Solve for global CTI constant. 

309 

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

311 

312 Parameters 

313 ---------- 

314 inputMeasurements : `list` [`dict`] 

315 List of overscan measurements from each input exposure. 

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

317 with measurements organized by amplifier name, containing 

318 keys: 

319 

320 ``"FIRST_MEAN"`` 

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

322 ``"LAST_MEAN"`` 

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

324 ``"IMAGE_MEAN"`` 

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

326 ``"OVERSCAN_COLUMNS"`` 

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

328 ``"OVERSCAN_VALUES"`` 

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

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

331 Calibration to populate with values. 

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

333 Detector object containing the geometry information for 

334 the amplifiers. 

335 

336 Returns 

337 ------- 

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

339 Populated calibration. 

340 

341 Notes 

342 ----- 

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

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

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

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

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

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

349 CTISIM. This offset removes that last imaging data column 

350 from the count. 

351 """ 

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

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

354 start, stop = self.config.globalCtiColumnRange 

355 start -= 1 

356 stop -= 1 

357 

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

359 # "non-saturated" inputs. 

360 for amp in detector.getAmplifiers(): 

361 ampName = amp.getName() 

362 

363 # Number of serial shifts. 

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

365 

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

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

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

369 # leaks into the overscan region. 

370 signal = [] 

371 data = [] 

372 Nskipped = 0 

373 for exposureEntry in inputMeasurements: 

374 exposureDict = exposureEntry['CTI'] 

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

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

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

378 else: 

379 Nskipped += 1 

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

381 

382 signal = np.array(signal) 

383 data = np.array(data) 

384 

385 ind = signal.argsort() 

386 signal = signal[ind] 

387 data = data[ind] 

388 

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

390 # the first few columns of the overscan. 

391 overscan1 = data[:, 0] 

392 overscan2 = data[:, 1] 

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

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

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

396 "full fitting will be performed" if testResult else 

397 "only global CTI fitting will be performed") 

398 

399 self.debugView(ampName, signal, test) 

400 

401 params = Parameters() 

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

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

404 vary=True if testResult else False) 

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

406 vary=True if testResult else False) 

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

408 vary=True if testResult else False) 

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

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

411 

412 model = SimulatedModel() 

413 minner = Minimizer(model.difference, params, 

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

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

416 result = minner.minimize() 

417 

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

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

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

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

422 calib.driftScale[ampName]) 

423 

424 return calib 

425 

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

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

428 

429 Parameters 

430 ---------- 

431 ampName : `str` 

432 Name of the amp for plot title. 

433 signal : `list` [`float`] 

434 Image means for the input exposures. 

435 test : `list` [`float`] 

436 CTI test value to plot. 

437 """ 

438 import lsstDebug 

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

440 return 

441 if not self.allowDebug: 

442 return 

443 

444 import matplotlib.pyplot as plot 

445 figure = plot.figure(1) 

446 figure.clear() 

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

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

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

450 plot.ylabel('Serial CTI') 

451 plot.title(ampName) 

452 plot.plot(signal, test) 

453 

454 figure.show() 

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

456 while True: 

457 ans = input(prompt).lower() 

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

459 break 

460 elif ans in ("p", ): 

461 import pdb 

462 pdb.set_trace() 

463 elif ans in ('x', ): 

464 self.allowDebug = False 

465 break 

466 elif ans in ("h", ): 

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

468 plot.close() 

469 

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

471 """Solve for serial trap parameters. 

472 

473 Parameters 

474 ---------- 

475 inputMeasurements : `list` [`dict`] 

476 List of overscan measurements from each input exposure. 

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

478 with measurements organized by amplifier name, containing 

479 keys: 

480 

481 ``"FIRST_MEAN"`` 

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

483 ``"LAST_MEAN"`` 

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

485 ``"IMAGE_MEAN"`` 

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

487 ``"OVERSCAN_COLUMNS"`` 

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

489 ``"OVERSCAN_VALUES"`` 

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

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

492 Calibration to populate with values. 

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

494 Detector object containing the geometry information for 

495 the amplifiers. 

496 

497 Returns 

498 ------- 

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

500 Populated calibration. 

501 

502 Notes 

503 ----- 

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

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

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

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

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

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

510 CTISIM. This offset removes that last imaging data column 

511 from the count. 

512 """ 

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

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

515 start, stop = self.config.trapColumnRange 

516 start -= 1 

517 stop -= 1 

518 

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

520 # "non-saturated" inputs. 

521 for amp in detector.getAmplifiers(): 

522 ampName = amp.getName() 

523 

524 # Number of serial shifts. 

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

526 

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

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

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

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

531 # overscan columns. 

532 signal = [] 

533 data = [] 

534 new_signal = [] 

535 Nskipped = 0 

536 for exposureEntry in inputMeasurements: 

537 exposureDict = exposureEntry['CTI'] 

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

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

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

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

542 else: 

543 Nskipped += 1 

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

545 

546 signal = np.array(signal) 

547 data = np.array(data) 

548 new_signal = np.array(new_signal) 

549 

550 ind = signal.argsort() 

551 signal = signal[ind] 

552 data = data[ind] 

553 new_signal = new_signal[ind] 

554 

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

556 # parameters already determined will match the observed 

557 # overscan results. 

558 params = Parameters() 

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

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

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

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

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

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

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

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

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

568 

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

570 start=start, stop=stop) 

571 

572 # Evaluating trap: the difference between the model and 

573 # observed data. 

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

575 

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

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

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

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

580 x = new_signal 

581 y = np.maximum(0, res) 

582 

583 # Pad left with ramp 

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

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

586 

587 # Pad right with constant 

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

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

590 

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

592 calib.serialTraps[ampName] = trap 

593 

594 return calib 

595 

596 

597class OverscanModel: 

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

599 

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

601 run. 

602 """ 

603 

604 @staticmethod 

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

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

607 fit parameters and input signal. 

608 

609 Parameters 

610 ---------- 

611 params : `lmfit.Parameters` 

612 Object containing the model parameters. 

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

614 Array of image means. 

615 num_transfers : `int` 

616 Number of serial transfers that the charge undergoes. 

617 start : `int`, optional 

618 First overscan column to fit. This number includes the 

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

620 using the overscan bounding box. 

621 stop : `int`, optional 

622 Last overscan column to fit. This number includes the 

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

624 using the overscan bounding box. 

625 

626 Returns 

627 ------- 

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

629 Model results. 

630 """ 

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

632 

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

634 """Calculate log likelihood of the model. 

635 

636 Parameters 

637 ---------- 

638 params : `lmfit.Parameters` 

639 Object containing the model parameters. 

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

641 Array of image means. 

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

643 Array of overscan column means from each measurement. 

644 error : `float` 

645 Fixed error value. 

646 *args : 

647 Additional position arguments. 

648 **kwargs : 

649 Additional keyword arguments. 

650 

651 Returns 

652 ------- 

653 logL : `float` 

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

655 parameters. 

656 """ 

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

658 

659 inv_sigma2 = 1.0/(error**2.0) 

660 diff = model_results - data 

661 

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

663 

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

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

666 

667 Parameters 

668 ---------- 

669 params : `lmfit.Parameters` 

670 Object containing the model parameters. 

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

672 Array of image means. 

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

674 Array of overscan column means from each measurement. 

675 error : `float` 

676 Fixed error value. 

677 *args : 

678 Additional position arguments. 

679 **kwargs : 

680 Additional keyword arguments. 

681 

682 Returns 

683 ------- 

684 negativelogL : `float` 

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

686 model parameters. 

687 """ 

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

689 

690 return -ll 

691 

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

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

694 

695 Parameters 

696 ---------- 

697 params : `lmfit.Parameters` 

698 Object containing the model parameters. 

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

700 Array of image means. 

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

702 Array of overscan column means from each measurement. 

703 error : `float` 

704 Fixed error value. 

705 *args : 

706 Additional position arguments. 

707 **kwargs : 

708 Additional keyword arguments. 

709 

710 Returns 

711 ------- 

712 rms : `float` 

713 The rms error between the model and input data. 

714 """ 

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

716 

717 diff = model_results - data 

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

719 

720 return rms 

721 

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

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

724 

725 Parameters 

726 ---------- 

727 params : `lmfit.Parameters` 

728 Object containing the model parameters. 

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

730 Array of image means. 

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

732 Array of overscan column means from each measurement. 

733 error : `float` 

734 Fixed error value. 

735 *args : 

736 Additional position arguments. 

737 **kwargs : 

738 Additional keyword arguments. 

739 

740 Returns 

741 ------- 

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

743 The rms error between the model and input data. 

744 """ 

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

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

747 

748 return diff 

749 

750 

751class SimpleModel(OverscanModel): 

752 """Simple analytic overscan model.""" 

753 

754 @staticmethod 

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

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

757 fit parameters and input signal. 

758 

759 Parameters 

760 ---------- 

761 params : `lmfit.Parameters` 

762 Object containing the model parameters. 

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

764 Array of image means. 

765 num_transfers : `int` 

766 Number of serial transfers that the charge undergoes. 

767 start : `int`, optional 

768 First overscan column to fit. This number includes the 

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

770 using the overscan bounding box. 

771 stop : `int`, optional 

772 Last overscan column to fit. This number includes the 

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

774 using the overscan bounding box. 

775 

776 Returns 

777 ------- 

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

779 Model results. 

780 """ 

781 v = params.valuesdict() 

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

783 

784 # Adjust column numbering to match DM overscan bbox. 

785 start += 1 

786 stop += 1 

787 

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

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

790 

791 for i, s in enumerate(signal): 

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

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

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

795 # This scales the exponential release of charge from the 

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

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

798 # includes the contribution from local CTI effects. 

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

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

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

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

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

804 

805 return res 

806 

807 

808class SimulatedModel(OverscanModel): 

809 """Simulated overscan model.""" 

810 

811 @staticmethod 

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

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

814 fit parameters and input signal. 

815 

816 Parameters 

817 ---------- 

818 params : `lmfit.Parameters` 

819 Object containing the model parameters. 

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

821 Array of image means. 

822 num_transfers : `int` 

823 Number of serial transfers that the charge undergoes. 

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

825 Amplifier to use for geometry information. 

826 start : `int`, optional 

827 First overscan column to fit. This number includes the 

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

829 using the overscan bounding box. 

830 stop : `int`, optional 

831 Last overscan column to fit. This number includes the 

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

833 using the overscan bounding box. 

834 trap_type : `str`, optional 

835 Type of trap model to use. 

836 

837 Returns 

838 ------- 

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

840 Model results. 

841 """ 

842 v = params.valuesdict() 

843 

844 # Adjust column numbering to match DM overscan bbox. 

845 start += 1 

846 stop += 1 

847 

848 # Electronics effect optimization 

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

850 

851 # CTI optimization 

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

853 

854 # Trap type for optimization 

855 if trap_type is None: 

856 trap = None 

857 elif trap_type == 'linear': 

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

859 [v['scaling']]) 

860 elif trap_type == 'logistic': 

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

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

863 else: 

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

865 

866 # Simulate ramp readout 

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

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

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

870 ramp.ramp_exp(signal) 

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

872 parallel_overscan_width=0) 

873 

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

875 

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

877 

878 

879class SegmentSimulator: 

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

881 

882 Parameters 

883 ---------- 

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

885 Image data array. 

886 prescan_width : `int` 

887 Number of serial prescan columns. 

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

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

890 cti : `float` 

891 Global CTI value. 

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

893 Serial traps to simulate. 

894 """ 

895 

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

897 # Image array geometry 

898 self.prescan_width = prescan_width 

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

900 

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

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

903 

904 # Serial readout information 

905 self.output_amplifier = output_amplifier 

906 if isinstance(cti, np.ndarray): 

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

908 self.cti = cti 

909 

910 self.serial_traps = None 

911 self.do_trapping = False 

912 if traps is not None: 

913 if not isinstance(traps, list): 

914 traps = [traps] 

915 for trap in traps: 

916 self.add_trap(trap) 

917 

918 def add_trap(self, serial_trap): 

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

920 

921 Parameters 

922 ---------- 

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

924 The trap to add. 

925 """ 

926 try: 

927 self.serial_traps.append(serial_trap) 

928 except AttributeError: 

929 self.serial_traps = [serial_trap] 

930 self.do_trapping = True 

931 

932 def ramp_exp(self, signal_list): 

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

934 

935 This method simulates a segment image where the signal level 

936 increases along the horizontal direction, according to the 

937 provided list of signal levels. 

938 

939 Parameters 

940 ---------- 

941 signal_list : `list` [`float`] 

942 List of signal levels. 

943 

944 Raises 

945 ------ 

946 ValueError 

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

948 number of rows. 

949 """ 

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

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

952 

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

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

955 

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

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

958 

959 This method performs the serial readout of a segment image 

960 given the appropriate SerialRegister object and the properties 

961 of the ReadoutAmplifier. Additional arguments can be provided 

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

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

964 

965 Parameters 

966 ---------- 

967 serial_overscan_width : `int`, optional 

968 Number of serial overscan columns. 

969 parallel_overscan_width : `int`, optional 

970 Number of parallel overscan rows. 

971 

972 Returns 

973 ------- 

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

975 Simulated image, including serial prescan, serial 

976 overscan, and parallel overscan regions. 

977 """ 

978 # Create output array 

979 iy = int(self.ny + parallel_overscan_width) 

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

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

982 scale=self.output_amplifier.noise, 

983 size=(iy, ix)) 

984 free_charge = copy.deepcopy(self.segarr) 

985 

986 # Set flow control parameters 

987 do_trapping = self.do_trapping 

988 cti = self.cti 

989 

990 offset = np.zeros(self.ny) 

991 cte = 1 - cti 

992 if do_trapping: 

993 for trap in self.serial_traps: 

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

995 

996 for i in range(ix): 

997 # Trap capture 

998 if do_trapping: 

999 for trap in self.serial_traps: 

1000 captured_charge = trap.trap_charge(free_charge) 

1001 free_charge -= captured_charge 

1002 

1003 # Pixel-to-pixel proportional loss 

1004 transferred_charge = free_charge*cte 

1005 deferred_charge = free_charge*cti 

1006 

1007 # Pixel transfer and readout 

1008 offset = self.output_amplifier.local_offset(offset, 

1009 transferred_charge[:, 0]) 

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

1011 

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

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

1014 

1015 # Trap emission 

1016 if do_trapping: 

1017 for trap in self.serial_traps: 

1018 released_charge = trap.release_charge() 

1019 free_charge += released_charge 

1020 

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

1022 

1023 

1024class FloatingOutputAmplifier: 

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

1026 

1027 Parameters 

1028 ---------- 

1029 gain : `float` 

1030 Amplifier gain. 

1031 scale : `float` 

1032 Drift scale for the amplifier. 

1033 decay_time : `float` 

1034 Decay time for the bias drift. 

1035 noise : `float`, optional 

1036 Amplifier read noise. 

1037 offset : `float`, optional 

1038 Global CTI offset. 

1039 """ 

1040 

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

1042 

1043 self.gain = gain 

1044 self.noise = noise 

1045 self.global_offset = offset 

1046 

1047 self.update_parameters(scale, decay_time) 

1048 

1049 def local_offset(self, old, signal): 

1050 """Calculate local offset hysteresis. 

1051 

1052 Parameters 

1053 ---------- 

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

1055 Previous iteration. 

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

1057 Current column measurements. 

1058 

1059 Returns 

1060 ------- 

1061 offset : `np.ndarray` 

1062 Local offset. 

1063 """ 

1064 new = self.scale*signal 

1065 

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

1067 

1068 def update_parameters(self, scale, decay_time): 

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

1070 

1071 Parameters 

1072 ---------- 

1073 scale : `float` 

1074 Drift scale for the amplifier. 

1075 decay_time : `float` 

1076 Decay time for the bias drift. 

1077 

1078 Raises 

1079 ------ 

1080 ValueError 

1081 Raised if the input parameters are out of range. 

1082 """ 

1083 if scale < 0.0: 

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

1085 if np.isnan(scale): 

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

1087 self.scale = scale 

1088 if decay_time <= 0.0: 

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

1090 if np.isnan(decay_time): 

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

1092 self.decay_time = decay_time