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

326 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-07-27 02:56 -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 

35class CpCtiSolveConnections(pipeBase.PipelineTaskConnections, 

36 dimensions=("instrument", "detector")): 

37 inputMeasurements = cT.Input( 

38 name="cpCtiMeas", 

39 doc="Input overscan measurements to fit.", 

40 storageClass='StructuredDataDict', 

41 dimensions=("instrument", "exposure", "detector"), 

42 multiple=True, 

43 ) 

44 camera = cT.PrerequisiteInput( 

45 name="camera", 

46 doc="Camera geometry to use.", 

47 storageClass="Camera", 

48 dimensions=("instrument", ), 

49 lookupFunction=lookupStaticCalibration, 

50 isCalibration=True, 

51 ) 

52 

53 outputCalib = cT.Output( 

54 name="cpCtiCalib", 

55 doc="Output CTI calibration.", 

56 storageClass="IsrCalib", 

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

58 ) 

59 

60 

61class CpCtiSolveConfig(pipeBase.PipelineTaskConfig, 

62 pipelineConnections=CpCtiSolveConnections): 

63 """Configuration for the CTI combination. 

64 """ 

65 maxImageMean = pexConfig.Field( 

66 dtype=float, 

67 default=150000.0, 

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

69 ) 

70 localOffsetColumnRange = pexConfig.ListField( 

71 dtype=int, 

72 default=[3, 13], 

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

74 ) 

75 

76 maxSignalForCti = pexConfig.Field( 

77 dtype=float, 

78 default=10000.0, 

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

80 ) 

81 globalCtiColumnRange = pexConfig.ListField( 

82 dtype=int, 

83 default=[1, 2], 

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

85 ) 

86 

87 trapColumnRange = pexConfig.ListField( 

88 dtype=int, 

89 default=[1, 20], 

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

91 ) 

92 

93 fitError = pexConfig.Field( 

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

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

96 dtype=float, 

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

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

99 ) 

100 

101 

102class CpCtiSolveTask(pipeBase.PipelineTask, 

103 pipeBase.CmdLineTask): 

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

105 

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

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

108 Telescopes, Instruments, and Systems, 7, 

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

110 """ 

111 

112 ConfigClass = CpCtiSolveConfig 

113 _DefaultName = 'cpCtiSolve' 

114 

115 def __init__(self, **kwargs): 

116 super().__init__(**kwargs) 

117 self.allowDebug = True 

118 

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

120 inputs = butlerQC.get(inputRefs) 

121 

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

123 inputs['inputDims'] = dimensions 

124 

125 outputs = self.run(**inputs) 

126 butlerQC.put(outputs, outputRefs) 

127 

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

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

130 

131 Parameters 

132 ---------- 

133 inputMeasurements : `list` [`dict`] 

134 List of overscan measurements from each input exposure. 

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

136 with measurements organized by amplifier name, containing 

137 keys: 

138 

139 ``"FIRST_MEAN"`` 

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

141 ``"LAST_MEAN"`` 

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

143 ``"IMAGE_MEAN"`` 

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

145 ``"OVERSCAN_COLUMNS"`` 

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

147 ``"OVERSCAN_VALUES"`` 

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

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

150 Camera geometry to use to find detectors. 

151 inputDims : `list` [`dict`] 

152 List of input dimensions from each input exposure. 

153 

154 Returns 

155 ------- 

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

157 Result struct containing: 

158 

159 ``outputCalib`` 

160 Final CTI calibration data 

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

162 

163 Raises 

164 ------ 

165 RuntimeError 

166 Raised if data from multiple detectors are passed in. 

167 """ 

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

169 if len(detectorSet) != 1: 

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

171 detectorId = detectorSet.pop() 

172 detector = camera[detectorId] 

173 

174 # Initialize with detector. 

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

176 

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

178 

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

180 

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

182 

183 return pipeBase.Struct( 

184 outputCalib=finalCalib, 

185 ) 

186 

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

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

189 

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

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

192 of proportionality. 

193 

194 Parameters 

195 ---------- 

196 inputMeasurements : `list` [`dict`] 

197 List of overscan measurements from each input exposure. 

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

199 with measurements organized by amplifier name, containing 

200 keys: 

201 

202 ``"FIRST_MEAN"`` 

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

204 ``"LAST_MEAN"`` 

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

206 ``"IMAGE_MEAN"`` 

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

208 ``"OVERSCAN_COLUMNS"`` 

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

210 ``"OVERSCAN_VALUES"`` 

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

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

213 Calibration to populate with values. 

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

215 Detector object containing the geometry information for 

216 the amplifiers. 

217 

218 Returns 

219 ------- 

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

221 Populated calibration. 

222 

223 Notes 

224 ----- 

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

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

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

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

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

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

231 CTISIM. This offset removes that last imaging data column 

232 from the count. 

233 """ 

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

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

236 start, stop = self.config.localOffsetColumnRange 

237 start -= 1 

238 stop -= 1 

239 

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

241 # "non-saturated" inputs. 

242 for amp in detector.getAmplifiers(): 

243 ampName = amp.getName() 

244 

245 # Number of serial shifts. 

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

247 

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

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

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

251 # leaks into the overscan region. 

252 signal = [] 

253 data = [] 

254 Nskipped = 0 

255 for exposureEntry in inputMeasurements: 

256 exposureDict = exposureEntry['CTI'] 

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

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

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

260 else: 

261 Nskipped += 1 

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

263 

264 signal = np.array(signal) 

265 data = np.array(data) 

266 

267 ind = signal.argsort() 

268 signal = signal[ind] 

269 data = data[ind] 

270 

271 params = Parameters() 

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

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

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

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

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

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

278 

279 model = SimpleModel() 

280 minner = Minimizer(model.difference, params, 

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

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

283 result = minner.minimize() 

284 

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

286 if not result.success: 

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

288 

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

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

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

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

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

294 calib.driftScale[ampName]) 

295 return calib 

296 

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

298 """Solve for global CTI constant. 

299 

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

301 

302 Parameters 

303 ---------- 

304 inputMeasurements : `list` [`dict`] 

305 List of overscan measurements from each input exposure. 

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

307 with measurements organized by amplifier name, containing 

308 keys: 

309 

310 ``"FIRST_MEAN"`` 

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

312 ``"LAST_MEAN"`` 

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

314 ``"IMAGE_MEAN"`` 

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

316 ``"OVERSCAN_COLUMNS"`` 

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

318 ``"OVERSCAN_VALUES"`` 

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

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

321 Calibration to populate with values. 

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

323 Detector object containing the geometry information for 

324 the amplifiers. 

325 

326 Returns 

327 ------- 

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

329 Populated calibration. 

330 

331 Notes 

332 ----- 

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

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

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

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

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

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

339 CTISIM. This offset removes that last imaging data column 

340 from the count. 

341 """ 

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

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

344 start, stop = self.config.globalCtiColumnRange 

345 start -= 1 

346 stop -= 1 

347 

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

349 # "non-saturated" inputs. 

350 for amp in detector.getAmplifiers(): 

351 ampName = amp.getName() 

352 

353 # Number of serial shifts. 

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

355 

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

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

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

359 # leaks into the overscan region. 

360 signal = [] 

361 data = [] 

362 Nskipped = 0 

363 for exposureEntry in inputMeasurements: 

364 exposureDict = exposureEntry['CTI'] 

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

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

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

368 else: 

369 Nskipped += 1 

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

371 

372 signal = np.array(signal) 

373 data = np.array(data) 

374 

375 ind = signal.argsort() 

376 signal = signal[ind] 

377 data = data[ind] 

378 

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

380 # the first few columns of the overscan. 

381 overscan1 = data[:, 0] 

382 overscan2 = data[:, 1] 

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

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

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

386 "full fitting will be performed" if testResult else 

387 "only global CTI fitting will be performed") 

388 

389 self.debugView(ampName, signal, test) 

390 

391 params = Parameters() 

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

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

394 vary=True if testResult else False) 

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

396 vary=True if testResult else False) 

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

398 vary=True if testResult else False) 

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

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

401 

402 model = SimulatedModel() 

403 minner = Minimizer(model.difference, params, 

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

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

406 result = minner.minimize() 

407 

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

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

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

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

412 calib.driftScale[ampName]) 

413 

414 return calib 

415 

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

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

418 

419 Parameters 

420 ---------- 

421 ampName : `str` 

422 Name of the amp for plot title. 

423 signal : `list` [`float`] 

424 Image means for the input exposures. 

425 test : `list` [`float`] 

426 CTI test value to plot. 

427 """ 

428 import lsstDebug 

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

430 return 

431 if not self.allowDebug: 

432 return 

433 

434 import matplotlib.pyplot as plot 

435 figure = plot.figure(1) 

436 figure.clear() 

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

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

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

440 plot.ylabel('Serial CTI') 

441 plot.title(ampName) 

442 plot.plot(signal, test) 

443 

444 figure.show() 

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

446 while True: 

447 ans = input(prompt).lower() 

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

449 break 

450 elif ans in ("p", ): 

451 import pdb 

452 pdb.set_trace() 

453 elif ans in ('x', ): 

454 self.allowDebug = False 

455 break 

456 elif ans in ("h", ): 

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

458 plot.close() 

459 

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

461 """Solve for serial trap parameters. 

462 

463 Parameters 

464 ---------- 

465 inputMeasurements : `list` [`dict`] 

466 List of overscan measurements from each input exposure. 

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

468 with measurements organized by amplifier name, containing 

469 keys: 

470 

471 ``"FIRST_MEAN"`` 

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

473 ``"LAST_MEAN"`` 

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

475 ``"IMAGE_MEAN"`` 

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

477 ``"OVERSCAN_COLUMNS"`` 

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

479 ``"OVERSCAN_VALUES"`` 

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

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

482 Calibration to populate with values. 

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

484 Detector object containing the geometry information for 

485 the amplifiers. 

486 

487 Returns 

488 ------- 

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

490 Populated calibration. 

491 

492 Notes 

493 ----- 

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

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

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

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

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

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

500 CTISIM. This offset removes that last imaging data column 

501 from the count. 

502 """ 

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

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

505 start, stop = self.config.trapColumnRange 

506 start -= 1 

507 stop -= 1 

508 

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

510 # "non-saturated" inputs. 

511 for amp in detector.getAmplifiers(): 

512 ampName = amp.getName() 

513 

514 # Number of serial shifts. 

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

516 

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

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

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

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

521 # overscan columns. 

522 signal = [] 

523 data = [] 

524 new_signal = [] 

525 Nskipped = 0 

526 for exposureEntry in inputMeasurements: 

527 exposureDict = exposureEntry['CTI'] 

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

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

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

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

532 else: 

533 Nskipped += 1 

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

535 

536 signal = np.array(signal) 

537 data = np.array(data) 

538 new_signal = np.array(new_signal) 

539 

540 ind = signal.argsort() 

541 signal = signal[ind] 

542 data = data[ind] 

543 new_signal = new_signal[ind] 

544 

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

546 # parameters already determined will match the observed 

547 # overscan results. 

548 params = Parameters() 

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

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

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

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

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

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

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

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

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

558 

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

560 start=start, stop=stop) 

561 

562 # Evaluating trap: the difference between the model and 

563 # observed data. 

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

565 

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

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

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

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

570 x = new_signal 

571 y = np.maximum(0, res) 

572 

573 # Pad left with ramp 

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

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

576 

577 # Pad right with constant 

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

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

580 

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

582 calib.serialTraps[ampName] = trap 

583 

584 return calib 

585 

586 

587class OverscanModel: 

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

589 

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

591 run. 

592 """ 

593 

594 @staticmethod 

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

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

597 fit parameters and input signal. 

598 

599 Parameters 

600 ---------- 

601 params : `lmfit.Parameters` 

602 Object containing the model parameters. 

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

604 Array of image means. 

605 num_transfers : `int` 

606 Number of serial transfers that the charge undergoes. 

607 start : `int`, optional 

608 First overscan column to fit. This number includes the 

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

610 using the overscan bounding box. 

611 stop : `int`, optional 

612 Last overscan column to fit. This number includes the 

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

614 using the overscan bounding box. 

615 

616 Returns 

617 ------- 

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

619 Model results. 

620 """ 

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

622 

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

624 """Calculate log likelihood of the model. 

625 

626 Parameters 

627 ---------- 

628 params : `lmfit.Parameters` 

629 Object containing the model parameters. 

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

631 Array of image means. 

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

633 Array of overscan column means from each measurement. 

634 error : `float` 

635 Fixed error value. 

636 *args : 

637 Additional position arguments. 

638 **kwargs : 

639 Additional keyword arguments. 

640 

641 Returns 

642 ------- 

643 logL : `float` 

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

645 parameters. 

646 """ 

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

648 

649 inv_sigma2 = 1.0/(error**2.0) 

650 diff = model_results - data 

651 

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

653 

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

655 """Calculate negative 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 negativelogL : `float` 

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

676 model parameters. 

677 """ 

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

679 

680 return -ll 

681 

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

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

684 

685 Parameters 

686 ---------- 

687 params : `lmfit.Parameters` 

688 Object containing the model parameters. 

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

690 Array of image means. 

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

692 Array of overscan column means from each measurement. 

693 error : `float` 

694 Fixed error value. 

695 *args : 

696 Additional position arguments. 

697 **kwargs : 

698 Additional keyword arguments. 

699 

700 Returns 

701 ------- 

702 rms : `float` 

703 The rms error between the model and input data. 

704 """ 

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

706 

707 diff = model_results - data 

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

709 

710 return rms 

711 

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

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

714 

715 Parameters 

716 ---------- 

717 params : `lmfit.Parameters` 

718 Object containing the model parameters. 

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

720 Array of image means. 

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

722 Array of overscan column means from each measurement. 

723 error : `float` 

724 Fixed error value. 

725 *args : 

726 Additional position arguments. 

727 **kwargs : 

728 Additional keyword arguments. 

729 

730 Returns 

731 ------- 

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

733 The rms error between the model and input data. 

734 """ 

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

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

737 

738 return diff 

739 

740 

741class SimpleModel(OverscanModel): 

742 """Simple analytic overscan model.""" 

743 

744 @staticmethod 

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

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

747 fit parameters and input signal. 

748 

749 Parameters 

750 ---------- 

751 params : `lmfit.Parameters` 

752 Object containing the model parameters. 

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

754 Array of image means. 

755 num_transfers : `int` 

756 Number of serial transfers that the charge undergoes. 

757 start : `int`, optional 

758 First overscan column to fit. This number includes the 

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

760 using the overscan bounding box. 

761 stop : `int`, optional 

762 Last overscan column to fit. This number includes the 

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

764 using the overscan bounding box. 

765 

766 Returns 

767 ------- 

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

769 Model results. 

770 """ 

771 v = params.valuesdict() 

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

773 

774 # Adjust column numbering to match DM overscan bbox. 

775 start += 1 

776 stop += 1 

777 

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

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

780 

781 for i, s in enumerate(signal): 

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

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

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

785 # This scales the exponential release of charge from the 

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

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

788 # includes the contribution from local CTI effects. 

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

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

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

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

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

794 

795 return res 

796 

797 

798class SimulatedModel(OverscanModel): 

799 """Simulated overscan model.""" 

800 

801 @staticmethod 

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

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

804 fit parameters and input signal. 

805 

806 Parameters 

807 ---------- 

808 params : `lmfit.Parameters` 

809 Object containing the model parameters. 

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

811 Array of image means. 

812 num_transfers : `int` 

813 Number of serial transfers that the charge undergoes. 

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

815 Amplifier to use for geometry information. 

816 start : `int`, optional 

817 First overscan column to fit. This number includes the 

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

819 using the overscan bounding box. 

820 stop : `int`, optional 

821 Last overscan column to fit. This number includes the 

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

823 using the overscan bounding box. 

824 trap_type : `str`, optional 

825 Type of trap model to use. 

826 

827 Returns 

828 ------- 

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

830 Model results. 

831 """ 

832 v = params.valuesdict() 

833 

834 # Adjust column numbering to match DM overscan bbox. 

835 start += 1 

836 stop += 1 

837 

838 # Electronics effect optimization 

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

840 

841 # CTI optimization 

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

843 

844 # Trap type for optimization 

845 if trap_type is None: 

846 trap = None 

847 elif trap_type == 'linear': 

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

849 [v['scaling']]) 

850 elif trap_type == 'logistic': 

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

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

853 else: 

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

855 

856 # Simulate ramp readout 

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

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

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

860 ramp.ramp_exp(signal) 

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

862 parallel_overscan_width=0) 

863 

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

865 

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

867 

868 

869class SegmentSimulator: 

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

871 

872 Parameters 

873 ---------- 

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

875 Image data array. 

876 prescan_width : `int` 

877 Number of serial prescan columns. 

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

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

880 cti : `float` 

881 Global CTI value. 

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

883 Serial traps to simulate. 

884 """ 

885 

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

887 # Image array geometry 

888 self.prescan_width = prescan_width 

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

890 

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

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

893 

894 # Serial readout information 

895 self.output_amplifier = output_amplifier 

896 if isinstance(cti, np.ndarray): 

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

898 self.cti = cti 

899 

900 self.serial_traps = None 

901 self.do_trapping = False 

902 if traps is not None: 

903 if not isinstance(traps, list): 

904 traps = [traps] 

905 for trap in traps: 

906 self.add_trap(trap) 

907 

908 def add_trap(self, serial_trap): 

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

910 

911 Parameters 

912 ---------- 

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

914 The trap to add. 

915 """ 

916 try: 

917 self.serial_traps.append(serial_trap) 

918 except AttributeError: 

919 self.serial_traps = [serial_trap] 

920 self.do_trapping = True 

921 

922 def ramp_exp(self, signal_list): 

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

924 

925 This method simulates a segment image where the signal level 

926 increases along the horizontal direction, according to the 

927 provided list of signal levels. 

928 

929 Parameters 

930 ---------- 

931 signal_list : `list` [`float`] 

932 List of signal levels. 

933 

934 Raises 

935 ------ 

936 ValueError 

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

938 number of rows. 

939 """ 

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

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

942 

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

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

945 

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

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

948 

949 This method performs the serial readout of a segment image 

950 given the appropriate SerialRegister object and the properties 

951 of the ReadoutAmplifier. Additional arguments can be provided 

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

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

954 

955 Parameters 

956 ---------- 

957 serial_overscan_width : `int`, optional 

958 Number of serial overscan columns. 

959 parallel_overscan_width : `int`, optional 

960 Number of parallel overscan rows. 

961 

962 Returns 

963 ------- 

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

965 Simulated image, including serial prescan, serial 

966 overscan, and parallel overscan regions. 

967 """ 

968 # Create output array 

969 iy = int(self.ny + parallel_overscan_width) 

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

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

972 scale=self.output_amplifier.noise, 

973 size=(iy, ix)) 

974 free_charge = copy.deepcopy(self.segarr) 

975 

976 # Set flow control parameters 

977 do_trapping = self.do_trapping 

978 cti = self.cti 

979 

980 offset = np.zeros(self.ny) 

981 cte = 1 - cti 

982 if do_trapping: 

983 for trap in self.serial_traps: 

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

985 

986 for i in range(ix): 

987 # Trap capture 

988 if do_trapping: 

989 for trap in self.serial_traps: 

990 captured_charge = trap.trap_charge(free_charge) 

991 free_charge -= captured_charge 

992 

993 # Pixel-to-pixel proportional loss 

994 transferred_charge = free_charge*cte 

995 deferred_charge = free_charge*cti 

996 

997 # Pixel transfer and readout 

998 offset = self.output_amplifier.local_offset(offset, 

999 transferred_charge[:, 0]) 

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

1001 

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

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

1004 

1005 # Trap emission 

1006 if do_trapping: 

1007 for trap in self.serial_traps: 

1008 released_charge = trap.release_charge() 

1009 free_charge += released_charge 

1010 

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

1012 

1013 

1014class FloatingOutputAmplifier: 

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

1016 

1017 Parameters 

1018 ---------- 

1019 gain : `float` 

1020 Amplifier gain. 

1021 scale : `float` 

1022 Drift scale for the amplifier. 

1023 decay_time : `float` 

1024 Decay time for the bias drift. 

1025 noise : `float`, optional 

1026 Amplifier read noise. 

1027 offset : `float`, optional 

1028 Global CTI offset. 

1029 """ 

1030 

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

1032 

1033 self.gain = gain 

1034 self.noise = noise 

1035 self.global_offset = offset 

1036 

1037 self.update_parameters(scale, decay_time) 

1038 

1039 def local_offset(self, old, signal): 

1040 """Calculate local offset hysteresis. 

1041 

1042 Parameters 

1043 ---------- 

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

1045 Previous iteration. 

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

1047 Current column measurements. 

1048 

1049 Returns 

1050 ------- 

1051 offset : `np.ndarray` 

1052 Local offset. 

1053 """ 

1054 new = self.scale*signal 

1055 

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

1057 

1058 def update_parameters(self, scale, decay_time): 

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

1060 

1061 Parameters 

1062 ---------- 

1063 scale : `float` 

1064 Drift scale for the amplifier. 

1065 decay_time : `float` 

1066 Decay time for the bias drift. 

1067 

1068 Raises 

1069 ------ 

1070 ValueError 

1071 Raised if the input parameters are out of range. 

1072 """ 

1073 if scale < 0.0: 

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

1075 if np.isnan(scale): 

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

1077 self.scale = scale 

1078 if decay_time <= 0.0: 

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

1080 if np.isnan(decay_time): 

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

1082 self.decay_time = decay_time