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

393 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-07 02:34 -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 pipeBase.CmdLineTask): 

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

144 

145 The first task of the PTC measurement pipeline, 

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

147 before this task), produced a list of 

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

149 contains the mean signal and covariances of the 

150 difference image of the flat-field images taken at 

151 the same exposure time. The list also contains dummy 

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

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

154 match. 

155 

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

157 of individual PTC datasets produced 

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

159 dataset, discarding the dummy datset as appropiate. 

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

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

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

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

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

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

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

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

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

169 

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

171 of CCD sensors", arXiv:1905.08677 

172 """ 

173 

174 ConfigClass = PhotonTransferCurveSolveConfig 

175 _DefaultName = 'cpPhotonTransferCurveSolve' 

176 

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

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

179 

180 Parameters 

181 ---------- 

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

183 Butler to operate on. 

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

185 Input data refs to load. 

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

187 Output data refs to persist. 

188 """ 

189 inputs = butlerQC.get(inputRefs) 

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

191 butlerQC.put(outputs, outputRefs) 

192 

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

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 inputExpList : `list` [`~lsst.afw.image.ExposureF`], optional 

203 List of exposures. 

204 

205 Returns 

206 ------- 

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

208 The resultins structure contains: 

209 ``outputPtcDatset`` 

210 Final PTC dataset, containing information such as the 

211 means, variances, and exposure times 

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

213 """ 

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

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

216 datasetPtc = PhotonTransferCurveDataset(ampNames, self.config.ptcFitType, 

217 self.config.maximumRangeCovariancesAstier) 

218 for partialPtcDataset in inputCovariances: 

219 # Ignore dummy datasets 

220 if partialPtcDataset.ptcFitType == 'DUMMY': 

221 continue 

222 for ampName in ampNames: 

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

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

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

226 else: 

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

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

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

230 else: 

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

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

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

234 else: 

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

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

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

238 else: 

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

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

241 datasetPtc.covariancesSqrtWeights[ampName].append( 

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

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

244 # rawMeans index 

245 for ampName in ampNames: 

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

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

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

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

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

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

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

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

254 datasetPtc.covariancesSqrtWeights[ampName])[index] 

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

256 # Fit the measured covariances vs mean signal to 

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

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

259 # signal (mu) curve using the EXPAPPROXIMATION model 

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

261 # get the flat pairs that are masked. The 

262 # points at these fluxes will also be masked when 

263 # calculating the other elements of the covariance 

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

265 

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

267 tempDatasetPtc = copy.copy(datasetPtc) 

268 tempDatasetPtc.ptcFitType = "EXPAPPROXIMATION" 

269 tempDatasetPtc = self.fitMeasurementsToModel(tempDatasetPtc) 

270 

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

272 # previous fit. 

273 for ampName in datasetPtc.ampNames: 

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

275 datasetPtc.fitType = "FULLCOVARIANCE" 

276 datasetPtc = self.fitMeasurementsToModel(datasetPtc) 

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

278 # ("EXPAPPROXIMATION", "POLYNOMIAL") 

279 else: 

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

281 # approximation (Eq. 16). Fill up 

282 # PhotonTransferCurveDataset object. 

283 datasetPtc = self.fitMeasurementsToModel(datasetPtc) 

284 if inputExpList is not None: 

285 # It should be a list of exposures, to get the detector. 

286 detector = inputExpList[0].getDetector() 

287 else: 

288 detector = None 

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

290 

291 return pipeBase.Struct( 

292 outputPtcDataset=datasetPtc, 

293 ) 

294 

295 def fitMeasurementsToModel(self, dataset): 

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

297 polynomial or one of the models in Astier+19 

298 (Eq. 16 or Eq.20). 

299 

300 Parameters 

301 ---------- 

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

303 The dataset containing information such as the means, 

304 (co)variances, and exposure times. 

305 

306 Returns 

307 ------- 

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

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

310 it has been modified to include information such as the 

311 fit vectors and the fit parameters. See the class 

312 `PhotonTransferCurveDatase`. 

313 """ 

314 fitType = dataset.ptcFitType 

315 if fitType in ["FULLCOVARIANCE", ]: 

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

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

318 # with variance = Cov_00 

319 dataset = self.fitDataFullCovariance(dataset) 

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

321 # The PTC is technically defined as variance vs signal 

322 dataset = self.fitPtc(dataset) 

323 else: 

324 raise RuntimeError( 

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

326 "'POLYNOMIAL', 'EXPAPPROXIMATION', or 'FULLCOVARIANCE'" 

327 ) 

328 

329 return dataset 

330 

331 def fitDataFullCovariance(self, dataset): 

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

333 Astier+19 (Eq. 20). 

334 

335 Parameters 

336 ---------- 

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

338 The dataset containing information such as the means, 

339 (co)variances, and exposure times. 

340 

341 Returns 

342 ------- 

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

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

345 it has been modified to include information such as the 

346 fit vectors and the fit parameters. See the class 

347 `PhotonTransferCurveDatase`. 

348 

349 Notes 

350 ----- 

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

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

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

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

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

356 gain, units: e/ADU 

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

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

359 

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

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

362 maximum lag considered for the covariances calculation, and the 

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

364 have r^2 fewer entries. 

365 """ 

366 matrixSide = self.config.maximumRangeCovariancesAstier 

367 lenParams = matrixSide*matrixSide 

368 

369 for ampName in dataset.ampNames: 

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

371 # Not used when ptcFitType is 'FULLCOVARIANCE' 

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

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

374 dataset.ptcFitChiSq[ampName] = np.nan 

375 

376 if ampName in dataset.badAmps: 

377 # Bad amp 

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

379 # with astropy.Table works. 

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

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

382 dataset.covariancesModel[ampName] = listNanMatrix 

383 dataset.covariancesSqrtWeights[ampName] = listNanMatrix 

384 dataset.aMatrix[ampName] = nanMatrix 

385 dataset.bMatrix[ampName] = nanMatrix 

386 dataset.covariancesModelNoB[ampName] = listNanMatrix 

387 dataset.aMatrixNoB[ampName] = nanMatrix 

388 

389 dataset.expIdMask[ampName] = np.repeat(np.nan, lenInputTimes) 

390 dataset.gain[ampName] = np.nan 

391 dataset.gainErr[ampName] = np.nan 

392 dataset.noise[ampName] = np.nan 

393 dataset.noiseErr[ampName] = np.nan 

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

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

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

397 continue 

398 

399 muAtAmp = dataset.rawMeans[ampName] 

400 maskAtAmp = dataset.expIdMask[ampName] 

401 if len(maskAtAmp) == 0: 

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

403 

404 muAtAmp = muAtAmp[maskAtAmp] 

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

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

407 

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

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

410 

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

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

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

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

415 'fullModelNoB': self.funcFullCovarianceModelNoB} 

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

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

418 for key in functionsDict: 

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

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

421 weightsY=covSqrtWeightsAtAmp.flatten()) 

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

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

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

425 gain = params[-1] 

426 

427 fitResults[key]['a'] = a 

428 fitResults[key]['c'] = c 

429 fitResults[key]['noise'] = noise 

430 fitResults[key]['gain'] = gain 

431 fitResults[key]['paramsErr'] = paramsErr 

432 

433 # Put the information in the PTC dataset 

434 

435 # Not used when ptcFitType is 'FULLCOVARIANCE' 

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

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

438 dataset.ptcFitChiSq[ampName] = np.nan 

439 

440 # Save full covariances, covariances models, and their weights 

441 # dataset.expIdMask is already full 

442 dataset.covariances[ampName] = covAtAmp 

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

444 fitResults['fullModel']['a'], 

445 fitResults['fullModel']['c'], 

446 fitResults['fullModel']['noise'], 

447 fitResults['fullModel']['gain']) 

448 dataset.covariancesSqrtWeights[ampName] = covSqrtWeightsAtAmp 

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

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

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

452 fitResults['fullModelNoB']['a'], 

453 fitResults['fullModelNoB']['c'], 

454 fitResults['fullModelNoB']['noise'], 

455 fitResults['fullModelNoB']['gain'], 

456 setBtoZero=True) 

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

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

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

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

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

462 dataset.noise[ampName] = readoutNoise 

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

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

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

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

467 dataset.finalMeans[ampName] = muAtAmp 

468 

469 return dataset 

470 

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

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

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

474 of Astier+19. 

475 

476 Parameters 

477 ---------- 

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

479 Signal `mu` (ADU) 

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

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

482 `M = config.maximumRangeCovariancesAstier`), 

483 indexed by mean signal `mu`. 

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

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

486 

487 Returns 

488 ------- 

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

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

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

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

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

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

495 gain : `float` 

496 Amplifier gain (e/ADU) 

497 """ 

498 matrixSide = self.config.maximumRangeCovariancesAstier 

499 

500 # Initialize fit parameters 

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

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

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

504 gain = 1. 

505 

506 # iterate the fit to account for higher orders 

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

508 # stop when it increases 

509 oldChi2 = 1e30 

510 for _ in range(5): 

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

512 # loop on lags 

513 for i in range(matrixSide): 

514 for j in range(matrixSide): 

515 # fit a parabola for a given lag 

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

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

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

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

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

521 if(i + j == 0): 

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

523 weightedRes = (model - cov)*sqrtW 

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

525 if chi2 > oldChi2: 

526 break 

527 oldChi2 = chi2 

528 

529 return a, c, noise, gain 

530 

531 def funcFullCovarianceModel(self, params, x): 

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

533 Astier+19. 

534 

535 Parameters 

536 ---------- 

537 params : `list` 

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

539 gain (e/ADU). 

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

541 Signal `mu` (ADU) 

542 

543 Returns 

544 ------- 

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

546 Covariance matrix. 

547 """ 

548 matrixSide = self.config.maximumRangeCovariancesAstier 

549 lenParams = matrixSide*matrixSide 

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

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

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

553 gain = params[-1] 

554 

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

556 

557 def funcFullCovarianceModelNoB(self, params, x): 

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

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

560 

561 Parameters 

562 ---------- 

563 params : `list` 

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

565 gain (e/ADU). 

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

567 Signal mu (ADU) 

568 

569 Returns 

570 ------- 

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

572 Covariance matrix. 

573 """ 

574 matrixSide = self.config.maximumRangeCovariancesAstier 

575 lenParams = matrixSide*matrixSide 

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

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

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

579 gain = params[-1] 

580 

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

582 

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

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

585 

586 Parameters 

587 ---------- 

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

589 List of mean signals. 

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

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

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

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

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

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

596 gain : `float` 

597 Amplifier gain (e/ADU) 

598 setBtoZero=False : `bool`, optional 

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

600 

601 Returns 

602 ------- 

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

604 Covariances model. 

605 

606 Notes 

607 ----- 

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

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

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

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

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

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

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

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

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

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

618 gain, units: e/ADU 

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

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

621 """ 

622 matrixSide = self.config.maximumRangeCovariancesAstier 

623 sa = (matrixSide, matrixSide) 

624 # pad a with zeros and symmetrize 

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

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

627 aSym = symmetrize(aEnlarged) 

628 # pad c with zeros and symmetrize 

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

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

631 cSym = symmetrize(cEnlarged) 

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

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

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

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

636 

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

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

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

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

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

642 

643 # assumes that mu is 1d 

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

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

646 # term, that is absent for now. 

647 if setBtoZero: 

648 c1 = np.zeros_like(c1) 

649 ac = np.zeros_like(ac) 

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

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

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

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

654 

655 return covModel 

656 

657 # EXPAPPROXIMATION and POLYNOMIAL fit methods 

658 @staticmethod 

659 def _initialParsForPolynomial(order): 

660 assert(order >= 2) 

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

662 pars[0] = 10 

663 pars[1] = 1 

664 pars[2:] = 0.0001 

665 return pars 

666 

667 @staticmethod 

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

669 if not len(lowers): 

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

671 if not len(uppers): 

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

673 lowers[1] = 0 # no negative gains 

674 return (lowers, uppers) 

675 

676 @staticmethod 

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

678 if not len(lowers): 

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

680 if not len(uppers): 

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

682 return (lowers, uppers) 

683 

684 @staticmethod 

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

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

687 

688 Parameters 

689 ---------- 

690 means : `numpy.array` 

691 Input array with mean signal values. 

692 variances : `numpy.array` 

693 Input array with variances at each mean value. 

694 minVarPivotSearch : `float` 

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

696 of decreasing variance should be sought. 

697 consecutivePointsVarDecreases : `int` 

698 Required number of consecutive points/fluxes 

699 in the PTC where the variance 

700 decreases in order to find a first 

701 estimate of the PTC turn-off. 

702 

703 Returns 

704 ------ 

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

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

707 points. 

708 

709 Notes 

710 ----- 

711 Eliminate points beyond which the variance decreases. 

712 """ 

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

714 # Variances are sorted and should monotonically increase 

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

716 if len(pivotList) > 0: 

717 # For small values, sometimes the variance decreases slightly 

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

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

720 # Require that the varince decreases during 

721 # consecutivePointsVarDecreases 

722 # consecutive points. This will give a first 

723 # estimate of the PTC turn-off, which 

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

725 if len(pivotList) > 1: 

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

727 # each value in pivotList. The lambda function subtracts 

728 # each value from the index. 

729 # groupby groups elements by equal key value. 

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

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

732 # Form groups of consecute values from pivotList 

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

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

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

736 # Find the first group of consecutive numbers when 

737 # variance decreases. 

738 if len(group) >= consecutivePointsVarDecreases: 

739 pivotIndex = np.min(group) 

740 goodPoints[pivotIndex+1:] = False 

741 break 

742 

743 return goodPoints 

744 

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

746 """""" 

747 array = np.array(array) 

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

749 if nBad == 0: 

750 return array 

751 

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

753 if len(index): 

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

755 self.log.warning(msg) 

756 

757 array[index] = substituteValue 

758 

759 return array 

760 

761 def fitPtc(self, dataset): 

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

763 Astier+19 approximation (Eq. 16). 

764 

765 Fit the photon transfer curve with either a polynomial of 

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

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

768 

769 Sigma clipping is performed iteratively for the fit, as 

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

771 than `config.initialNonLinearityExclusionThreshold` away 

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

773 because the photon transfer curve turns over catastrophically 

774 at very high flux (because saturation 

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

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

777 to perform the sigma-clipping. 

778 

779 Parameters 

780 ---------- 

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

782 The dataset containing the means, variances and 

783 exposure times. 

784 

785 Returns 

786 ------- 

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

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

789 it has been modified to include information such as the 

790 fit vectors and the fit parameters. See the class 

791 `PhotonTransferCurveDatase`. 

792 

793 Raises 

794 ------ 

795 RuntimeError: 

796 Raises if dataset.ptcFitType is None or empty. 

797 """ 

798 if dataset.ptcFitType: 

799 ptcFitType = dataset.ptcFitType 

800 else: 

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

802 matrixSide = self.config.maximumRangeCovariancesAstier 

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

804 nanMatrix[:] = np.nan 

805 

806 for amp in dataset.ampNames: 

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

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

809 listNanMatrix[:] = np.nan 

810 

811 dataset.covariancesModel[amp] = listNanMatrix 

812 dataset.aMatrix[amp] = nanMatrix 

813 dataset.bMatrix[amp] = nanMatrix 

814 dataset.covariancesModelNoB[amp] = listNanMatrix 

815 dataset.aMatrixNoB[amp] = nanMatrix 

816 

817 def errFunc(p, x, y): 

818 return ptcFunc(p, x) - y 

819 

820 sigmaCutPtcOutliers = self.config.sigmaCutPtcOutliers 

821 maxIterationsPtcOutliers = self.config.maxIterationsPtcOutliers 

822 

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

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

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

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

827 varVecOriginal = self._makeZeroSafe(varVecOriginal) 

828 

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

830 # consecutive signal levels 

831 goodPoints = self._getInitialGoodPoints(meanVecOriginal, varVecOriginal, 

832 self.config.minVarPivotSearch, 

833 self.config.consecutivePointsVarDecreases) 

834 

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

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

837 

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

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

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

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

842 self.log.warning(msg) 

843 # Fill entries with NaNs 

844 self.fillBadAmp(dataset, ptcFitType, ampName) 

845 continue 

846 

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

848 # PTC turnoff point 

849 ptcTurnoff = meanVecOriginal[goodPoints][-1] 

850 dataset.ptcTurnoff[ampName] = ptcTurnoff 

851 

852 mask = goodPoints 

853 

854 if ptcFitType == 'EXPAPPROXIMATION': 

855 ptcFunc = funcAstier 

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

857 # lowers and uppers obtained from BOT data studies by 

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

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

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

861 if ptcFitType == 'POLYNOMIAL': 

862 ptcFunc = funcPolynomial 

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

864 bounds = self._boundsForPolynomial(parsIniPtc) 

865 

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

867 # This further process of outlier rejection be skipped 

868 # if self.config.maxIterationsPtcOutliers = 0. 

869 # We already did some initial outlier rejection above in 

870 # self._getInitialGoodPoints. 

871 count = 1 

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

873 pars = parsIniPtc 

874 while count <= maxIterationsPtcOutliers: 

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

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

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

878 meanTempVec = meanVecOriginal[mask] 

879 varTempVec = varVecOriginal[mask] 

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

881 pars = res.x 

882 

883 # change this to the original from the temp because 

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

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

886 # same length for broadcasting 

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

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

889 mask = mask & newMask 

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

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

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

893 self.log.warning(msg) 

894 # Fill entries with NaNs 

895 self.fillBadAmp(dataset, ptcFitType, ampName) 

896 break 

897 nDroppedTotal = Counter(mask)[False] 

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

899 count, nDroppedTotal, ampName) 

900 count += 1 

901 # objects should never shrink 

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

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

904 continue 

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

906 # store the final mask 

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

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

909 else: 

910 dataset.expIdMask[ampName] = mask 

911 parsIniPtc = pars 

912 meanVecFinal = meanVecOriginal[mask] 

913 varVecFinal = varVecOriginal[mask] 

914 

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

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

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

918 

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

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

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

922 self.log.warning(msg) 

923 # Fill entries with NaNs 

924 self.fillBadAmp(dataset, ptcFitType, ampName) 

925 continue 

926 # Fit the PTC 

927 if self.config.doFitBootstrap: 

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

929 varVecFinal, ptcFunc, 

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

931 else: 

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

933 varVecFinal, ptcFunc, 

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

935 dataset.ptcFitPars[ampName] = parsFit 

936 dataset.ptcFitParsError[ampName] = parsFitErr 

937 dataset.ptcFitChiSq[ampName] = reducedChiSqPtc 

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

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

940 # mask may vary per amp). 

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

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

943 constant_values=np.nan) 

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

945 'constant', constant_values=np.nan) 

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

947 constant_values=np.nan) 

948 if ptcFitType == 'EXPAPPROXIMATION': 

949 ptcGain = parsFit[1] 

950 ptcGainErr = parsFitErr[1] 

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

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

953 if ptcFitType == 'POLYNOMIAL': 

954 ptcGain = 1./parsFit[1] 

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

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

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

958 dataset.gain[ampName] = ptcGain 

959 dataset.gainErr[ampName] = ptcGainErr 

960 dataset.noise[ampName] = ptcNoise 

961 dataset.noiseErr[ampName] = ptcNoiseErr 

962 

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

964 dataset.ptcFitType = ptcFitType 

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

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

967 

968 return dataset 

969 

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

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

972 good points. 

973 

974 Parameters 

975 ---------- 

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

977 The dataset containing the means, variances and 

978 exposure times. 

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

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

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

982 ampName : `str` 

983 Amplifier name. 

984 """ 

985 dataset.badAmps.append(ampName) 

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

987 dataset.gain[ampName] = np.nan 

988 dataset.gainErr[ampName] = np.nan 

989 dataset.noise[ampName] = np.nan 

990 dataset.noiseErr[ampName] = np.nan 

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

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

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

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

995 dataset.ptcFitChiSq[ampName] = np.nan 

996 dataset.ptcTurnoff[ampName] = np.nan 

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

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

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

1000 

1001 return