Coverage for python/lsst/cp/pipe/ptc/cpSolvePtcTask.py: 11%

395 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-08-05 01:28 -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 numpy as np 

23from collections import Counter 

24 

25import lsst.pex.config as pexConfig 

26import lsst.pipe.base as pipeBase 

27from lsst.cp.pipe.utils import (fitLeastSq, fitBootstrap, funcPolynomial, funcAstier, symmetrize) 

28 

29from scipy.signal import fftconvolve 

30from scipy.optimize import least_squares 

31from itertools import groupby 

32from operator import itemgetter 

33 

34import lsst.pipe.base.connectionTypes as cT 

35 

36from lsst.ip.isr import PhotonTransferCurveDataset 

37 

38from lsst.cp.pipe._lookupStaticCalibration import lookupStaticCalibration 

39 

40import copy 

41 

42 

43__all__ = ['PhotonTransferCurveSolveConfig', 'PhotonTransferCurveSolveTask'] 

44 

45 

46class PhotonTransferCurveSolveConnections(pipeBase.PipelineTaskConnections, 

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

48 inputCovariances = cT.Input( 

49 name="ptcCovariances", 

50 doc="Tuple with measured covariances from flats.", 

51 storageClass="PhotonTransferCurveDataset", 

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

53 multiple=True, 

54 ) 

55 camera = cT.PrerequisiteInput( 

56 name="camera", 

57 doc="Camera the input data comes from.", 

58 storageClass="Camera", 

59 dimensions=("instrument",), 

60 isCalibration=True, 

61 lookupFunction=lookupStaticCalibration, 

62 ) 

63 outputPtcDataset = cT.Output( 

64 name="ptcDatsetProposal", 

65 doc="Output proposed ptc dataset.", 

66 storageClass="PhotonTransferCurveDataset", 

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

68 multiple=False, 

69 isCalibration=True, 

70 ) 

71 

72 

73class PhotonTransferCurveSolveConfig(pipeBase.PipelineTaskConfig, 

74 pipelineConnections=PhotonTransferCurveSolveConnections): 

75 """Configuration for fitting measured covariances. 

76 """ 

77 

78 ptcFitType = pexConfig.ChoiceField( 

79 dtype=str, 

80 doc="Fit PTC to Eq. 16, Eq. 20 in Astier+19, or to a polynomial.", 

81 default="POLYNOMIAL", 

82 allowed={ 

83 "POLYNOMIAL": "n-degree polynomial (use 'polynomialFitDegree' to set 'n').", 

84 "EXPAPPROXIMATION": "Approximation in Astier+19 (Eq. 16).", 

85 "FULLCOVARIANCE": "Full covariances model in Astier+19 (Eq. 20)" 

86 } 

87 ) 

88 maximumRangeCovariancesAstier = pexConfig.Field( 

89 dtype=int, 

90 doc="Maximum range of covariances as in Astier+19", 

91 default=8, 

92 ) 

93 sigmaClipFullFitCovariancesAstier = pexConfig.Field( 

94 dtype=float, 

95 doc="sigma clip for full model fit for FULLCOVARIANCE ptcFitType ", 

96 default=5.0, 

97 ) 

98 maxIterFullFitCovariancesAstier = pexConfig.Field( 

99 dtype=int, 

100 doc="Maximum number of iterations in full model fit for FULLCOVARIANCE ptcFitType", 

101 default=3, 

102 ) 

103 polynomialFitDegree = pexConfig.Field( 

104 dtype=int, 

105 doc="Degree of polynomial to fit the PTC, when 'ptcFitType'=POLYNOMIAL.", 

106 default=3, 

107 ) 

108 sigmaCutPtcOutliers = pexConfig.Field( 

109 dtype=float, 

110 doc="Sigma cut for outlier rejection in PTC.", 

111 default=5.0, 

112 ) 

113 maxIterationsPtcOutliers = pexConfig.RangeField( 

114 dtype=int, 

115 doc="Maximum number of iterations for outlier rejection in PTC.", 

116 default=2, 

117 min=0 

118 ) 

119 minVarPivotSearch = pexConfig.Field( 

120 dtype=float, 

121 doc="The code looks for a pivot signal point after which the variance starts decreasing at high-flux" 

122 " to exclude then from the PTC model fit. However, sometimes at low fluxes, the variance" 

123 " decreases slightly. Set this variable for the variance value, in ADU^2, after which the pivot " 

124 " should be sought.", 

125 default=10000, 

126 ) 

127 consecutivePointsVarDecreases = pexConfig.RangeField( 

128 dtype=int, 

129 doc="Required number of consecutive points/fluxes in the PTC where the variance " 

130 "decreases in order to find a first estimate of the PTC turn-off. ", 

131 default=2, 

132 min=2 

133 ) 

134 doFitBootstrap = pexConfig.Field( 

135 dtype=bool, 

136 doc="Use bootstrap for the PTC fit parameters and errors?.", 

137 default=False, 

138 ) 

139 

140 

141class PhotonTransferCurveSolveTask(pipeBase.PipelineTask): 

142 """Task to fit the PTC from flat covariances. 

143 

144 The first task of the PTC measurement pipeline, 

145 ``PhotonTransferCurveMeasureTask`` (and assumed to have been run 

146 before this task), produced a list of 

147 `~lsst.ip.isr.PhotonTransferCurveDataset` objects. Each dataset 

148 contains the mean signal and covariances of the 

149 difference image of the flat-field images taken at 

150 the same exposure time. The list also contains dummy 

151 datasets (with no measurements), whose purpose is to have 

152 the input and output dimensions of ``PhotonTransferCurveMeasureTask`` 

153 match. 

154 

155 This task, ``PhotonTransferCurveSolveTask``, assembles the list 

156 of individual PTC datasets produced 

157 by ``PhotonTransferCurveMeasureTask`` into one single final PTC 

158 dataset, discarding the dummy datset as appropiate. 

159 The task fits the measured (co)variances to one of three models: 

160 a polynomial model of a given order, or the models described 

161 in equations 16 and 20 of Astier+19. These options are referred 

162 to as ``POLYNOMIAL``, ``EXPAPPROXIMATION``, and ``FULLCOVARIANCE`` 

163 in the configuration options of the task, respectively). 

164 Parameters of interest such as the gain and noise are derived 

165 from the fits. The ``FULLCOVARIANCE`` model is fitted to the 

166 full covariance data (as oppossed to the other two models, which 

167 are fit to the variance vs mean measurements only). 

168 

169 Astier+19: "The Shape of the Photon Transfer Curve 

170 of CCD sensors", arXiv:1905.08677 

171 """ 

172 

173 ConfigClass = PhotonTransferCurveSolveConfig 

174 _DefaultName = 'cpPhotonTransferCurveSolve' 

175 

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

177 """Ensure that the input and output dimensions are passed along. 

178 

179 Parameters 

180 ---------- 

181 butlerQC : `~lsst.daf.butler.butlerQuantumContext.ButlerQuantumContext` 

182 Butler to operate on. 

183 inputRefs : `~lsst.pipe.base.connections.InputQuantizedConnection` 

184 Input data refs to load. 

185 ouptutRefs : `~lsst.pipe.base.connections.OutputQuantizedConnection` 

186 Output data refs to persist. 

187 """ 

188 inputs = butlerQC.get(inputRefs) 

189 detId = inputRefs.inputCovariances[0].dataId['detector'] 

190 outputs = self.run(inputCovariances=inputs['inputCovariances'], camera=inputs['camera'], detId=detId) 

191 butlerQC.put(outputs, outputRefs) 

192 

193 def run(self, inputCovariances, camera=None, detId=0): 

194 """Fit measured covariances to different models. 

195 

196 Parameters 

197 ---------- 

198 inputCovariances : `list` [`lsst.ip.isr.PhotonTransferCurveDataset`] 

199 List of lsst.ip.isr.PhotonTransferCurveDataset datasets. 

200 camera : `lsst.afw.cameraGeom.Camera`, optional 

201 Input camera. 

202 detId : `int` 

203 Detector ID to locate the detector in the camera and 

204 populate the `lsst.ip.isr.PhotonTransferCurveDataset` 

205 metadata. 

206 Returns 

207 ------- 

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

209 The resultins structure contains: 

210 ``outputPtcDatset`` 

211 Final PTC dataset, containing information such as the 

212 means, variances, and exposure times 

213 (`lsst.ip.isr.PhotonTransferCurveDataset`). 

214 """ 

215 # Assemble individual PTC datasets into a single PTC dataset. 

216 ampNames = np.unique(inputCovariances[0].ampNames) 

217 datasetPtc = PhotonTransferCurveDataset(ampNames=ampNames, 

218 ptcFitType=self.config.ptcFitType, 

219 covMatrixSide=self.config.maximumRangeCovariancesAstier) 

220 for partialPtcDataset in inputCovariances: 

221 # Ignore dummy datasets 

222 if partialPtcDataset.ptcFitType == 'DUMMY': 

223 continue 

224 for ampName in ampNames: 

225 datasetPtc.inputExpIdPairs[ampName].append(partialPtcDataset.inputExpIdPairs[ampName]) 

226 if type(partialPtcDataset.rawExpTimes[ampName]) is list: 

227 datasetPtc.rawExpTimes[ampName].append(partialPtcDataset.rawExpTimes[ampName][0]) 

228 else: 

229 datasetPtc.rawExpTimes[ampName].append(partialPtcDataset.rawExpTimes[ampName]) 

230 if type(partialPtcDataset.rawMeans[ampName]) is list: 

231 datasetPtc.rawMeans[ampName].append(partialPtcDataset.rawMeans[ampName][0]) 

232 else: 

233 datasetPtc.rawMeans[ampName].append(partialPtcDataset.rawMeans[ampName]) 

234 if type(partialPtcDataset.rawVars[ampName]) is list: 

235 datasetPtc.rawVars[ampName].append(partialPtcDataset.rawVars[ampName][0]) 

236 else: 

237 datasetPtc.rawVars[ampName].append(partialPtcDataset.rawVars[ampName]) 

238 if type(partialPtcDataset.expIdMask[ampName]) is list: 

239 datasetPtc.expIdMask[ampName].append(partialPtcDataset.expIdMask[ampName][0]) 

240 else: 

241 datasetPtc.expIdMask[ampName].append(partialPtcDataset.expIdMask[ampName]) 

242 datasetPtc.covariances[ampName].append(np.array(partialPtcDataset.covariances[ampName][0])) 

243 datasetPtc.covariancesSqrtWeights[ampName].append( 

244 np.array(partialPtcDataset.covariancesSqrtWeights[ampName][0])) 

245 # Sort arrays that are filled so far in the final dataset by 

246 # rawMeans index 

247 for ampName in ampNames: 

248 index = np.argsort(np.ravel(np.array(datasetPtc.rawMeans[ampName]))) 

249 datasetPtc.inputExpIdPairs[ampName] = np.array(datasetPtc.inputExpIdPairs[ampName])[index] 

250 datasetPtc.rawExpTimes[ampName] = np.array(datasetPtc.rawExpTimes[ampName])[index] 

251 datasetPtc.rawMeans[ampName] = np.array(datasetPtc.rawMeans[ampName])[index] 

252 datasetPtc.rawVars[ampName] = np.array(datasetPtc.rawVars[ampName])[index] 

253 datasetPtc.expIdMask[ampName] = np.array(datasetPtc.expIdMask[ampName])[index] 

254 datasetPtc.covariances[ampName] = np.array(datasetPtc.covariances[ampName])[index] 

255 datasetPtc.covariancesSqrtWeights[ampName] = np.array( 

256 datasetPtc.covariancesSqrtWeights[ampName])[index] 

257 if self.config.ptcFitType == "FULLCOVARIANCE": 

258 # Fit the measured covariances vs mean signal to 

259 # the Astier+19 full model (Eq. 20). Before that 

260 # do a preliminary fit to the variance (C_00) vs mean 

261 # signal (mu) curve using the EXPAPPROXIMATION model 

262 # (Eq. 16 in Astier+19) in order to 

263 # get the flat pairs that are masked. The 

264 # points at these fluxes will also be masked when 

265 # calculating the other elements of the covariance 

266 # matrix, C_ij, i!=j). 

267 

268 # Preliminary fit, usign a temp dataset to get the mask 

269 tempDatasetPtc = copy.copy(datasetPtc) 

270 tempDatasetPtc.ptcFitType = "EXPAPPROXIMATION" 

271 tempDatasetPtc = self.fitMeasurementsToModel(tempDatasetPtc) 

272 

273 # "FULLCOVARIANCE", using the mask obtained from the 

274 # previous fit. 

275 for ampName in datasetPtc.ampNames: 

276 datasetPtc.expIdMask[ampName] = tempDatasetPtc.expIdMask[ampName] 

277 datasetPtc.fitType = "FULLCOVARIANCE" 

278 datasetPtc = self.fitMeasurementsToModel(datasetPtc) 

279 # The other options are: self.config.ptcFitType in 

280 # ("EXPAPPROXIMATION", "POLYNOMIAL") 

281 else: 

282 # Fit the PTC to a polynomial or to Astier+19 exponential 

283 # approximation (Eq. 16). Fill up 

284 # PhotonTransferCurveDataset object. 

285 datasetPtc = self.fitMeasurementsToModel(datasetPtc) 

286 

287 if camera: 

288 detector = camera[detId] 

289 else: 

290 detector = None 

291 datasetPtc.updateMetadata(setDate=True, camera=camera, detector=detector) 

292 

293 return pipeBase.Struct( 

294 outputPtcDataset=datasetPtc, 

295 ) 

296 

297 def fitMeasurementsToModel(self, dataset): 

298 """Fit the measured covariances vs mean signal to a 

299 polynomial or one of the models in Astier+19 

300 (Eq. 16 or Eq.20). 

301 

302 Parameters 

303 ---------- 

304 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset` 

305 The dataset containing information such as the means, 

306 (co)variances, and exposure times. 

307 

308 Returns 

309 ------- 

310 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset` 

311 This is the same dataset as the input parameter, however, 

312 it has been modified to include information such as the 

313 fit vectors and the fit parameters. See the class 

314 `PhotonTransferCurveDatase`. 

315 """ 

316 fitType = dataset.ptcFitType 

317 if fitType in ["FULLCOVARIANCE", ]: 

318 # This model uses the full covariance matrix in the fit. 

319 # The PTC is technically defined as variance vs signal, 

320 # with variance = Cov_00 

321 dataset = self.fitDataFullCovariance(dataset) 

322 elif fitType in ["POLYNOMIAL", "EXPAPPROXIMATION"]: 

323 # The PTC is technically defined as variance vs signal 

324 dataset = self.fitPtc(dataset) 

325 else: 

326 raise RuntimeError( 

327 f"Fitting option {fitType} not one of " 

328 "'POLYNOMIAL', 'EXPAPPROXIMATION', or 'FULLCOVARIANCE'" 

329 ) 

330 

331 return dataset 

332 

333 def fitDataFullCovariance(self, dataset): 

334 """Fit measured flat covariances to the full model in 

335 Astier+19 (Eq. 20). 

336 

337 Parameters 

338 ---------- 

339 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset` 

340 The dataset containing information such as the means, 

341 (co)variances, and exposure times. 

342 

343 Returns 

344 ------- 

345 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset` 

346 This is the same dataset as the input parameter, however, 

347 it has been modified to include information such as the 

348 fit vectors and the fit parameters. See the class 

349 `PhotonTransferCurveDatase`. 

350 

351 Notes 

352 ----- 

353 The parameters of the full model for C_ij(mu) ("C_ij" and "mu" 

354 in ADU^2 and ADU, respectively) in Astier+19 (Eq. 20) are: 

355 "a" coefficients (r by r matrix), units: 1/e 

356 "b" coefficients (r by r matrix), units: 1/e 

357 noise matrix (r by r matrix), units: e^2 

358 gain, units: e/ADU 

359 "b" appears in Eq. 20 only through the "ab" combination, which 

360 is defined in this code as "c=ab". 

361 

362 Total number of parameters: #entries(a) + #entries(c) + #entries(noise) 

363 + 1. This is equivalent to r^2 + r^2 + r^2 + 1, where "r" is the 

364 maximum lag considered for the covariances calculation, and the 

365 extra "1" is the gain. If "b" is 0, then "c" is 0, and len(pInit) will 

366 have r^2 fewer entries. 

367 """ 

368 matrixSide = self.config.maximumRangeCovariancesAstier 

369 lenParams = matrixSide*matrixSide 

370 

371 for ampName in dataset.ampNames: 

372 lenInputTimes = len(dataset.rawExpTimes[ampName]) 

373 # Not used when ptcFitType is 'FULLCOVARIANCE' 

374 dataset.ptcFitPars[ampName] = [np.nan] 

375 dataset.ptcFitParsError[ampName] = [np.nan] 

376 dataset.ptcFitChiSq[ampName] = np.nan 

377 

378 if ampName in dataset.badAmps: 

379 # Bad amp 

380 # Entries need to have proper dimensions so read/write 

381 # with astropy.Table works. 

382 nanMatrix = np.full((matrixSide, matrixSide), np.nan) 

383 listNanMatrix = np.full((lenInputTimes, matrixSide, matrixSide), np.nan) 

384 dataset.covariancesModel[ampName] = listNanMatrix 

385 dataset.covariancesSqrtWeights[ampName] = listNanMatrix 

386 dataset.aMatrix[ampName] = nanMatrix 

387 dataset.bMatrix[ampName] = nanMatrix 

388 dataset.covariancesModelNoB[ampName] = listNanMatrix 

389 dataset.aMatrixNoB[ampName] = nanMatrix 

390 

391 dataset.expIdMask[ampName] = np.repeat(False, lenInputTimes) 

392 dataset.gain[ampName] = np.nan 

393 dataset.gainErr[ampName] = np.nan 

394 dataset.noise[ampName] = np.nan 

395 dataset.noiseErr[ampName] = np.nan 

396 dataset.finalVars[ampName] = np.repeat(np.nan, lenInputTimes) 

397 dataset.finalModelVars[ampName] = np.repeat(np.nan, lenInputTimes) 

398 dataset.finalMeans[ampName] = np.repeat(np.nan, lenInputTimes) 

399 continue 

400 

401 muAtAmp = dataset.rawMeans[ampName] 

402 maskAtAmp = dataset.expIdMask[ampName] 

403 if len(maskAtAmp) == 0: 

404 maskAtAmp = np.repeat(True, len(muAtAmp)) 

405 

406 muAtAmp = muAtAmp[maskAtAmp] 

407 covAtAmp = np.nan_to_num(dataset.covariances[ampName])[maskAtAmp] 

408 covSqrtWeightsAtAmp = np.nan_to_num(dataset.covariancesSqrtWeights[ampName])[maskAtAmp] 

409 

410 # Initial fit, to approximate parameters, with c=0 

411 a0, c0, noise0, gain0 = self.initialFitFullCovariance(muAtAmp, covAtAmp, covSqrtWeightsAtAmp) 

412 

413 # Fit full model (Eq. 20 of Astier+19) and same model with 

414 # b=0 (c=0 in this code) 

415 pInit = np.concatenate((a0.flatten(), c0.flatten(), noise0.flatten(), np.array(gain0)), axis=None) 

416 functionsDict = {'fullModel': self.funcFullCovarianceModel, 

417 'fullModelNoB': self.funcFullCovarianceModelNoB} 

418 fitResults = {'fullModel': {'a': [], 'c': [], 'noise': [], 'gain': [], 'paramsErr': []}, 

419 'fullModelNoB': {'a': [], 'c': [], 'noise': [], 'gain': [], 'paramsErr': []}} 

420 for key in functionsDict: 

421 params, paramsErr, _ = fitLeastSq(pInit, muAtAmp, 

422 covAtAmp.flatten(), functionsDict[key], 

423 weightsY=covSqrtWeightsAtAmp.flatten()) 

424 a = params[:lenParams].reshape((matrixSide, matrixSide)) 

425 c = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide)) 

426 noise = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide)) 

427 gain = params[-1] 

428 

429 fitResults[key]['a'] = a 

430 fitResults[key]['c'] = c 

431 fitResults[key]['noise'] = noise 

432 fitResults[key]['gain'] = gain 

433 fitResults[key]['paramsErr'] = paramsErr 

434 

435 # Put the information in the PTC dataset 

436 

437 # Not used when ptcFitType is 'FULLCOVARIANCE' 

438 dataset.ptcFitPars[ampName] = [np.nan] 

439 dataset.ptcFitParsError[ampName] = [np.nan] 

440 dataset.ptcFitChiSq[ampName] = np.nan 

441 

442 # Save full covariances, covariances models, and their weights. 

443 # dataset.expIdMask is already full, but needs to be 

444 # converted to bool. 

445 dataset.expIdMask[ampName] = np.array(dataset.expIdMask[ampName], dtype=bool) 

446 dataset.covariances[ampName] = covAtAmp 

447 dataset.covariancesModel[ampName] = self.evalCovModel(muAtAmp, 

448 fitResults['fullModel']['a'], 

449 fitResults['fullModel']['c'], 

450 fitResults['fullModel']['noise'], 

451 fitResults['fullModel']['gain']) 

452 dataset.covariancesSqrtWeights[ampName] = covSqrtWeightsAtAmp 

453 dataset.aMatrix[ampName] = fitResults['fullModel']['a'] 

454 dataset.bMatrix[ampName] = fitResults['fullModel']['c']/fitResults['fullModel']['a'] 

455 dataset.covariancesModelNoB[ampName] = self.evalCovModel(muAtAmp, 

456 fitResults['fullModelNoB']['a'], 

457 fitResults['fullModelNoB']['c'], 

458 fitResults['fullModelNoB']['noise'], 

459 fitResults['fullModelNoB']['gain'], 

460 setBtoZero=True) 

461 dataset.aMatrixNoB[ampName] = fitResults['fullModelNoB']['a'] 

462 dataset.gain[ampName] = fitResults['fullModel']['gain'] 

463 dataset.gainErr[ampName] = fitResults['fullModel']['paramsErr'][-1] 

464 readoutNoise = fitResults['fullModel']['noise'][0][0] 

465 readoutNoiseSqrt = np.sqrt(np.fabs(readoutNoise)) 

466 dataset.noise[ampName] = readoutNoise 

467 readoutNoiseSigma = fitResults['fullModel']['paramsErr'][2*lenParams] 

468 dataset.noiseErr[ampName] = 0.5*(readoutNoiseSigma/np.fabs(readoutNoise))*readoutNoiseSqrt 

469 dataset.finalVars[ampName] = covAtAmp[:, 0, 0] 

470 dataset.finalModelVars[ampName] = dataset.covariancesModel[ampName][:, 0, 0] 

471 dataset.finalMeans[ampName] = muAtAmp 

472 

473 return dataset 

474 

475 def initialFitFullCovariance(self, mu, cov, sqrtW): 

476 """ Performs a crude parabolic fit of the data in order to start 

477 the full fit close to the solution, setting b=0 (c=0) in Eq. 20 

478 of Astier+19. 

479 

480 Parameters 

481 ---------- 

482 mu : `numpy.array`, (N,) 

483 Signal `mu` (ADU) 

484 cov : `numpy.array`, (N, M, M) 

485 Covariance arrays of size `(M, M)` (with 

486 `M = config.maximumRangeCovariancesAstier`), 

487 indexed by mean signal `mu`. 

488 sqrtW : `numpy.array`, (N,) 

489 Covariance weights, defined as 1./sqrt(Variances) 

490 

491 Returns 

492 ------- 

493 a : `numpy.array`, (M, M) 

494 "a" parameter per flux in Eq. 20 of Astier+19. 

495 c : `numpy.array`, (M, M) 

496 "c"="ab" parameter per flux in Eq. 20 of Astier+19. 

497 noise : `numpy.array`, (M, M) 

498 "noise" parameter per flux in Eq. 20 of Astier+19. 

499 gain : `float` 

500 Amplifier gain (e/ADU) 

501 """ 

502 matrixSide = self.config.maximumRangeCovariancesAstier 

503 

504 # Initialize fit parameters 

505 a = np.zeros((matrixSide, matrixSide)) 

506 c = np.zeros((matrixSide, matrixSide)) 

507 noise = np.zeros((matrixSide, matrixSide)) 

508 gain = 1. 

509 

510 # iterate the fit to account for higher orders 

511 # the chi2 does not necessarily go down, so one could 

512 # stop when it increases 

513 oldChi2 = 1e30 

514 for _ in range(5): 

515 model = np.nan_to_num(self.evalCovModel(mu, a, c, noise, gain, setBtoZero=True)) 

516 # loop on lags 

517 for i in range(matrixSide): 

518 for j in range(matrixSide): 

519 # fit a parabola for a given lag 

520 parsFit = np.polyfit(mu, cov[:, i, j] - model[:, i, j], 

521 2, w=sqrtW[:, i, j]) 

522 # model equation (Eq. 20) in Astier+19, with c=a*b=0: 

523 a[i, j] += parsFit[0] 

524 noise[i, j] += parsFit[2] 

525 if(i + j == 0): 

526 gain = 1./(1/gain+parsFit[1]) 

527 weightedRes = (model - cov)*sqrtW 

528 chi2 = (weightedRes.flatten()**2).sum() 

529 if chi2 > oldChi2: 

530 break 

531 oldChi2 = chi2 

532 

533 return a, c, noise, gain 

534 

535 def funcFullCovarianceModel(self, params, x): 

536 """Model to fit covariances from flat fields; Equation 20 of 

537 Astier+19. 

538 

539 Parameters 

540 ---------- 

541 params : `list` 

542 Parameters of the model: aMatrix, CMatrix, noiseMatrix, 

543 gain (e/ADU). 

544 x : `numpy.array`, (N,) 

545 Signal `mu` (ADU) 

546 

547 Returns 

548 ------- 

549 y : `numpy.array`, (N,) 

550 Covariance matrix. 

551 """ 

552 matrixSide = self.config.maximumRangeCovariancesAstier 

553 lenParams = matrixSide*matrixSide 

554 aMatrix = params[:lenParams].reshape((matrixSide, matrixSide)) 

555 cMatrix = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide)) 

556 noiseMatrix = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide)) 

557 gain = params[-1] 

558 

559 return self.evalCovModel(x, aMatrix, cMatrix, noiseMatrix, gain).flatten() 

560 

561 def funcFullCovarianceModelNoB(self, params, x): 

562 """Model to fit covariances from flat fields; Equation 20 of 

563 Astier+19, with b=0 (equivalent to c=a*b=0 in this code). 

564 

565 Parameters 

566 ---------- 

567 params : `list` 

568 Parameters of the model: aMatrix, CMatrix, noiseMatrix, 

569 gain (e/ADU). 

570 x : `numpy.array`, (N,) 

571 Signal mu (ADU) 

572 

573 Returns 

574 ------- 

575 y : `numpy.array`, (N,) 

576 Covariance matrix. 

577 """ 

578 matrixSide = self.config.maximumRangeCovariancesAstier 

579 lenParams = matrixSide*matrixSide 

580 aMatrix = params[:lenParams].reshape((matrixSide, matrixSide)) 

581 cMatrix = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide)) 

582 noiseMatrix = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide)) 

583 gain = params[-1] 

584 

585 return self.evalCovModel(x, aMatrix, cMatrix, noiseMatrix, gain, setBtoZero=True).flatten() 

586 

587 def evalCovModel(self, mu, aMatrix, cMatrix, noiseMatrix, gain, setBtoZero=False): 

588 """Computes full covariances model (Eq. 20 of Astier+19). 

589 

590 Parameters 

591 ---------- 

592 mu : `numpy.array`, (N,) 

593 List of mean signals. 

594 aMatrix : `numpy.array`, (M, M) 

595 "a" parameter per flux in Eq. 20 of Astier+19. 

596 cMatrix : `numpy.array`, (M, M) 

597 "c"="ab" parameter per flux in Eq. 20 of Astier+19. 

598 noiseMatrix : `numpy.array`, (M, M) 

599 "noise" parameter per flux in Eq. 20 of Astier+19. 

600 gain : `float` 

601 Amplifier gain (e/ADU) 

602 setBtoZero=False : `bool`, optional 

603 Set "b" parameter in full model (see Astier+19) to zero. 

604 

605 Returns 

606 ------- 

607 covModel : `numpy.array`, (N, M, M) 

608 Covariances model. 

609 

610 Notes 

611 ----- 

612 By default, computes the covModel for the mu's stored(self.mu). 

613 Returns cov[Nmu, M, M]. The variance for the PTC is 

614 cov[:, 0, 0]. mu and cov are in ADUs and ADUs squared. To use 

615 electrons for both, the gain should be set to 1. This routine 

616 implements the model in Astier+19 (1905.08677). 

617 The parameters of the full model for C_ij(mu) ("C_ij" and "mu" 

618 in ADU^2 and ADU, respectively) in Astier+19 (Eq. 20) are: 

619 "a" coefficients (M by M matrix), units: 1/e 

620 "b" coefficients (M by M matrix), units: 1/e 

621 noise matrix (M by M matrix), units: e^2 

622 gain, units: e/ADU 

623 "b" appears in Eq. 20 only through the "ab" combination, which 

624 is defined in this code as "c=ab". 

625 """ 

626 matrixSide = self.config.maximumRangeCovariancesAstier 

627 sa = (matrixSide, matrixSide) 

628 # pad a with zeros and symmetrize 

629 aEnlarged = np.zeros((int(sa[0]*1.5)+1, int(sa[1]*1.5)+1)) 

630 aEnlarged[0:sa[0], 0:sa[1]] = aMatrix 

631 aSym = symmetrize(aEnlarged) 

632 # pad c with zeros and symmetrize 

633 cEnlarged = np.zeros((int(sa[0]*1.5)+1, int(sa[1]*1.5)+1)) 

634 cEnlarged[0:sa[0], 0:sa[1]] = cMatrix 

635 cSym = symmetrize(cEnlarged) 

636 a2 = fftconvolve(aSym, aSym, mode='same') 

637 a3 = fftconvolve(a2, aSym, mode='same') 

638 ac = fftconvolve(aSym, cSym, mode='same') 

639 (xc, yc) = np.unravel_index(np.abs(aSym).argmax(), a2.shape) 

640 

641 a1 = aMatrix[np.newaxis, :, :] 

642 a2 = a2[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide] 

643 a3 = a3[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide] 

644 ac = ac[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide] 

645 c1 = cMatrix[np.newaxis, ::] 

646 

647 # assumes that mu is 1d 

648 bigMu = mu[:, np.newaxis, np.newaxis]*gain 

649 # c(=a*b in Astier+19) also has a contribution to the last 

650 # term, that is absent for now. 

651 if setBtoZero: 

652 c1 = np.zeros_like(c1) 

653 ac = np.zeros_like(ac) 

654 covModel = (bigMu/(gain*gain)*(a1*bigMu+2./3.*(bigMu*bigMu)*(a2 + c1) 

655 + (1./3.*a3 + 5./6.*ac)*(bigMu*bigMu*bigMu)) + noiseMatrix[np.newaxis, :, :]/gain**2) 

656 # add the Poisson term, and the read out noise (variance) 

657 covModel[:, 0, 0] += mu/gain 

658 

659 return covModel 

660 

661 # EXPAPPROXIMATION and POLYNOMIAL fit methods 

662 @staticmethod 

663 def _initialParsForPolynomial(order): 

664 assert(order >= 2) 

665 pars = np.zeros(order, dtype=float) 

666 pars[0] = 10 

667 pars[1] = 1 

668 pars[2:] = 0.0001 

669 return pars 

670 

671 @staticmethod 

672 def _boundsForPolynomial(initialPars, lowers=[], uppers=[]): 

673 if not len(lowers): 

674 lowers = [np.NINF for p in initialPars] 

675 if not len(uppers): 

676 uppers = [np.inf for p in initialPars] 

677 lowers[1] = 0 # no negative gains 

678 return (lowers, uppers) 

679 

680 @staticmethod 

681 def _boundsForAstier(initialPars, lowers=[], uppers=[]): 

682 if not len(lowers): 

683 lowers = [np.NINF for p in initialPars] 

684 if not len(uppers): 

685 uppers = [np.inf for p in initialPars] 

686 return (lowers, uppers) 

687 

688 @staticmethod 

689 def _getInitialGoodPoints(means, variances, minVarPivotSearch, consecutivePointsVarDecreases): 

690 """Return a boolean array to mask bad points. 

691 

692 Parameters 

693 ---------- 

694 means : `numpy.array` 

695 Input array with mean signal values. 

696 variances : `numpy.array` 

697 Input array with variances at each mean value. 

698 minVarPivotSearch : `float` 

699 The variance (in ADU^2), above which, the point 

700 of decreasing variance should be sought. 

701 consecutivePointsVarDecreases : `int` 

702 Required number of consecutive points/fluxes 

703 in the PTC where the variance 

704 decreases in order to find a first 

705 estimate of the PTC turn-off. 

706 

707 Returns 

708 ------ 

709 goodPoints : `numpy.array` [`bool`] 

710 Boolean array to select good (`True`) and bad (`False`) 

711 points. 

712 

713 Notes 

714 ----- 

715 Eliminate points beyond which the variance decreases. 

716 """ 

717 goodPoints = np.ones_like(means, dtype=bool) 

718 # Variances are sorted and should monotonically increase 

719 pivotList = np.where(np.array(np.diff(variances)) < 0)[0] 

720 if len(pivotList) > 0: 

721 # For small values, sometimes the variance decreases slightly 

722 # Only look when var > self.config.minVarPivotSearch 

723 pivotList = [p for p in pivotList if variances[p] > minVarPivotSearch] 

724 # Require that the varince decreases during 

725 # consecutivePointsVarDecreases 

726 # consecutive points. This will give a first 

727 # estimate of the PTC turn-off, which 

728 # may be updated (reduced) further in the code. 

729 if len(pivotList) > 1: 

730 # enumerate(pivotList) creates tuples (index, value), for 

731 # each value in pivotList. The lambda function subtracts 

732 # each value from the index. 

733 # groupby groups elements by equal key value. 

734 for k, g in groupby(enumerate(pivotList), lambda x: x[0]-x[1]): 

735 group = (map(itemgetter(1), g)) 

736 # Form groups of consecute values from pivotList 

737 group = list(map(int, group)) 

738 # values in pivotList are indices where np.diff(variances) 

739 # is negative, i.e., where the variance starts decreasing. 

740 # Find the first group of consecutive numbers when 

741 # variance decreases. 

742 if len(group) >= consecutivePointsVarDecreases: 

743 pivotIndex = np.min(group) 

744 goodPoints[pivotIndex+1:] = False 

745 break 

746 

747 return goodPoints 

748 

749 def _makeZeroSafe(self, array, substituteValue=1e-9): 

750 """""" 

751 array = np.array(array) 

752 nBad = Counter(np.ravel(array))[0] 

753 if nBad == 0: 

754 return array 

755 

756 index, = np.where(array == 0) 

757 if len(index): 

758 msg = f"Found {nBad} zeros in array at elements {index}" 

759 self.log.warning(msg) 

760 

761 array[index] = substituteValue 

762 

763 return array 

764 

765 def fitPtc(self, dataset): 

766 """Fit the photon transfer curve to a polynomial or to the 

767 Astier+19 approximation (Eq. 16). 

768 

769 Fit the photon transfer curve with either a polynomial of 

770 the order specified in the task config, or using the 

771 exponential approximation in Astier+19 (Eq. 16). 

772 

773 Sigma clipping is performed iteratively for the fit, as 

774 well as an initial clipping of data points that are more 

775 than `config.initialNonLinearityExclusionThreshold` away 

776 from lying on a straight line. This other step is necessary 

777 because the photon transfer curve turns over catastrophically 

778 at very high flux (because saturation 

779 drops the variance to ~0) and these far outliers cause the 

780 initial fit to fail, meaning the sigma cannot be calculated 

781 to perform the sigma-clipping. 

782 

783 Parameters 

784 ---------- 

785 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset` 

786 The dataset containing the means, variances and 

787 exposure times. 

788 

789 Returns 

790 ------- 

791 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset` 

792 This is the same dataset as the input parameter, however, 

793 it has been modified to include information such as the 

794 fit vectors and the fit parameters. See the class 

795 `PhotonTransferCurveDatase`. 

796 

797 Raises 

798 ------ 

799 RuntimeError: 

800 Raises if dataset.ptcFitType is None or empty. 

801 """ 

802 if dataset.ptcFitType: 

803 ptcFitType = dataset.ptcFitType 

804 else: 

805 raise RuntimeError("ptcFitType is None of empty in PTC dataset.") 

806 matrixSide = self.config.maximumRangeCovariancesAstier 

807 nanMatrix = np.empty((matrixSide, matrixSide)) 

808 nanMatrix[:] = np.nan 

809 

810 for amp in dataset.ampNames: 

811 lenInputTimes = len(dataset.rawExpTimes[amp]) 

812 listNanMatrix = np.empty((lenInputTimes, matrixSide, matrixSide)) 

813 listNanMatrix[:] = np.nan 

814 

815 dataset.covariancesModel[amp] = listNanMatrix 

816 dataset.aMatrix[amp] = nanMatrix 

817 dataset.bMatrix[amp] = nanMatrix 

818 dataset.covariancesModelNoB[amp] = listNanMatrix 

819 dataset.aMatrixNoB[amp] = nanMatrix 

820 

821 def errFunc(p, x, y): 

822 return ptcFunc(p, x) - y 

823 

824 sigmaCutPtcOutliers = self.config.sigmaCutPtcOutliers 

825 maxIterationsPtcOutliers = self.config.maxIterationsPtcOutliers 

826 

827 for i, ampName in enumerate(dataset.ampNames): 

828 timeVecOriginal = np.ravel(np.array(dataset.rawExpTimes[ampName])) 

829 meanVecOriginal = np.ravel(np.array(dataset.rawMeans[ampName])) 

830 varVecOriginal = np.ravel(np.array(dataset.rawVars[ampName])) 

831 varVecOriginal = self._makeZeroSafe(varVecOriginal) 

832 

833 # Discard points when the variance starts to decrease after two 

834 # consecutive signal levels 

835 goodPoints = self._getInitialGoodPoints(meanVecOriginal, varVecOriginal, 

836 self.config.minVarPivotSearch, 

837 self.config.consecutivePointsVarDecreases) 

838 

839 # Check if all points are bad from the 'cpExtractPtcTask' 

840 initialExpIdMask = np.ravel(np.array(dataset.expIdMask[ampName])) 

841 

842 if not (goodPoints.any() and initialExpIdMask.any()): 

843 msg = (f"SERIOUS: All points in goodPoints: {goodPoints} or " 

844 f"in initialExpIdMask: {initialExpIdMask} are bad." 

845 f"Setting {ampName} to BAD.") 

846 self.log.warning(msg) 

847 # Fill entries with NaNs 

848 self.fillBadAmp(dataset, ptcFitType, ampName) 

849 continue 

850 

851 # Save the point where the variance starts decreasing as the 

852 # PTC turnoff point 

853 ptcTurnoff = meanVecOriginal[goodPoints][-1] 

854 dataset.ptcTurnoff[ampName] = ptcTurnoff 

855 

856 mask = goodPoints 

857 

858 if ptcFitType == 'EXPAPPROXIMATION': 

859 ptcFunc = funcAstier 

860 parsIniPtc = [-1e-9, 1.0, 10.] # a00, gain, noise^2 

861 # lowers and uppers obtained from BOT data studies by 

862 # C. Lage (UC Davis, 11/2020). 

863 bounds = self._boundsForAstier(parsIniPtc, lowers=[-1e-4, 0.5, -2000], 

864 uppers=[1e-4, 2.5, 2000]) 

865 if ptcFitType == 'POLYNOMIAL': 

866 ptcFunc = funcPolynomial 

867 parsIniPtc = self._initialParsForPolynomial(self.config.polynomialFitDegree + 1) 

868 bounds = self._boundsForPolynomial(parsIniPtc) 

869 

870 # Before bootstrap fit, do an iterative fit to get rid of outliers. 

871 # This further process of outlier rejection be skipped 

872 # if self.config.maxIterationsPtcOutliers = 0. 

873 # We already did some initial outlier rejection above in 

874 # self._getInitialGoodPoints. 

875 count = 1 

876 newMask = np.ones_like(meanVecOriginal, dtype=bool) 

877 pars = parsIniPtc 

878 while count <= maxIterationsPtcOutliers: 

879 # Note that application of the mask actually shrinks the array 

880 # to size rather than setting elements to zero (as we want) so 

881 # always update mask itself and re-apply to the original data 

882 meanTempVec = meanVecOriginal[mask] 

883 varTempVec = varVecOriginal[mask] 

884 res = least_squares(errFunc, parsIniPtc, bounds=bounds, args=(meanTempVec, varTempVec)) 

885 pars = res.x 

886 

887 # change this to the original from the temp because 

888 # the masks are ANDed meaning once a point is masked 

889 # it's always masked, and the masks must always be the 

890 # same length for broadcasting 

891 sigResids = (varVecOriginal - ptcFunc(pars, meanVecOriginal))/np.sqrt(varVecOriginal) 

892 newMask = np.array([True if np.abs(r) < sigmaCutPtcOutliers else False for r in sigResids]) 

893 mask = mask & newMask 

894 if not (mask.any() and newMask.any()): 

895 msg = (f"SERIOUS: All points in either mask: {mask} or newMask: {newMask} are bad. " 

896 f"Setting {ampName} to BAD.") 

897 self.log.warning(msg) 

898 # Fill entries with NaNs 

899 self.fillBadAmp(dataset, ptcFitType, ampName) 

900 break 

901 nDroppedTotal = Counter(mask)[False] 

902 self.log.debug("Iteration %d: discarded %d points in total for %s", 

903 count, nDroppedTotal, ampName) 

904 count += 1 

905 # objects should never shrink 

906 assert (len(mask) == len(timeVecOriginal) == len(meanVecOriginal) == len(varVecOriginal)) 

907 if not (mask.any() and newMask.any()): 

908 continue 

909 dataset.expIdMask[ampName] = np.array(dataset.expIdMask[ampName]) 

910 # store the final mask 

911 if len(dataset.expIdMask[ampName]): 

912 dataset.expIdMask[ampName] &= mask # bitwise_and if there is already a mask 

913 else: 

914 dataset.expIdMask[ampName] = mask 

915 parsIniPtc = pars 

916 meanVecFinal = meanVecOriginal[mask] 

917 varVecFinal = varVecOriginal[mask] 

918 

919 if Counter(mask)[False] > 0: 

920 self.log.info("Number of points discarded in PTC of amplifier %s:" 

921 " %d out of %d", ampName, Counter(mask)[False], len(meanVecOriginal)) 

922 

923 if (len(meanVecFinal) < len(parsIniPtc)): 

924 msg = (f"SERIOUS: Not enough data points ({len(meanVecFinal)}) compared to the number of " 

925 f"parameters of the PTC model({len(parsIniPtc)}). Setting {ampName} to BAD.") 

926 self.log.warning(msg) 

927 # Fill entries with NaNs 

928 self.fillBadAmp(dataset, ptcFitType, ampName) 

929 continue 

930 # Fit the PTC 

931 if self.config.doFitBootstrap: 

932 parsFit, parsFitErr, reducedChiSqPtc = fitBootstrap(parsIniPtc, meanVecFinal, 

933 varVecFinal, ptcFunc, 

934 weightsY=1./np.sqrt(varVecFinal)) 

935 else: 

936 parsFit, parsFitErr, reducedChiSqPtc = fitLeastSq(parsIniPtc, meanVecFinal, 

937 varVecFinal, ptcFunc, 

938 weightsY=1./np.sqrt(varVecFinal)) 

939 dataset.ptcFitPars[ampName] = parsFit 

940 dataset.ptcFitParsError[ampName] = parsFitErr 

941 dataset.ptcFitChiSq[ampName] = reducedChiSqPtc 

942 # Masked variances (measured and modeled) and means. Need 

943 # to pad the array so astropy.Table does not crash (the 

944 # mask may vary per amp). 

945 padLength = len(dataset.rawExpTimes[ampName]) - len(varVecFinal) 

946 dataset.finalVars[ampName] = np.pad(varVecFinal, (0, padLength), 'constant', 

947 constant_values=np.nan) 

948 dataset.finalModelVars[ampName] = np.pad(ptcFunc(parsFit, meanVecFinal), (0, padLength), 

949 'constant', constant_values=np.nan) 

950 dataset.finalMeans[ampName] = np.pad(meanVecFinal, (0, padLength), 'constant', 

951 constant_values=np.nan) 

952 if ptcFitType == 'EXPAPPROXIMATION': 

953 ptcGain = parsFit[1] 

954 ptcGainErr = parsFitErr[1] 

955 ptcNoise = np.sqrt(np.fabs(parsFit[2])) 

956 ptcNoiseErr = 0.5*(parsFitErr[2]/np.fabs(parsFit[2]))*np.sqrt(np.fabs(parsFit[2])) 

957 if ptcFitType == 'POLYNOMIAL': 

958 ptcGain = 1./parsFit[1] 

959 ptcGainErr = np.fabs(1./parsFit[1])*(parsFitErr[1]/parsFit[1]) 

960 ptcNoise = np.sqrt(np.fabs(parsFit[0]))*ptcGain 

961 ptcNoiseErr = (0.5*(parsFitErr[0]/np.fabs(parsFit[0]))*(np.sqrt(np.fabs(parsFit[0]))))*ptcGain 

962 dataset.gain[ampName] = ptcGain 

963 dataset.gainErr[ampName] = ptcGainErr 

964 dataset.noise[ampName] = ptcNoise 

965 dataset.noiseErr[ampName] = ptcNoiseErr 

966 

967 if not len(dataset.ptcFitType) == 0: 

968 dataset.ptcFitType = ptcFitType 

969 if len(dataset.badAmps) == 0: 

970 dataset.badAmps = np.repeat(np.nan, len(list(dataset.rawExpTimes.values())[0])) 

971 

972 return dataset 

973 

974 def fillBadAmp(self, dataset, ptcFitType, ampName): 

975 """Fill the dataset with NaNs if there are not enough 

976 good points. 

977 

978 Parameters 

979 ---------- 

980 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset` 

981 The dataset containing the means, variances and 

982 exposure times. 

983 ptcFitType : {'POLYNOMIAL', 'EXPAPPROXIMATION'} 

984 Fit a 'POLYNOMIAL' (degree: 'polynomialFitDegree') or 

985 'EXPAPPROXIMATION' (Eq. 16 of Astier+19) to the PTC. 

986 ampName : `str` 

987 Amplifier name. 

988 """ 

989 dataset.badAmps.append(ampName) 

990 dataset.expIdMask[ampName] = np.repeat(False, len(dataset.rawExpTimes[ampName])) 

991 dataset.gain[ampName] = np.nan 

992 dataset.gainErr[ampName] = np.nan 

993 dataset.noise[ampName] = np.nan 

994 dataset.noiseErr[ampName] = np.nan 

995 dataset.ptcFitPars[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1) if 

996 ptcFitType in ["POLYNOMIAL", ] else np.repeat(np.nan, 3)) 

997 dataset.ptcFitParsError[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1) if 

998 ptcFitType in ["POLYNOMIAL", ] else np.repeat(np.nan, 3)) 

999 dataset.ptcFitChiSq[ampName] = np.nan 

1000 dataset.ptcTurnoff[ampName] = np.nan 

1001 dataset.finalVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName])) 

1002 dataset.finalModelVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName])) 

1003 dataset.finalMeans[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName])) 

1004 

1005 return