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

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

390 statements  

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 

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

202 Input camera. 

203 

204 inputExpList : `list` [`~lsst.afw.image.ExposureF`], optional 

205 List of exposures. 

206 

207 Returns 

208 ------- 

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

210 The results struct containing: 

211 

212 ``outputPtcDatset`` 

213 Final PTC dataset, containing information such as the 

214 means, variances, and exposure times 

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

216 """ 

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

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

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

220 self.config.maximumRangeCovariancesAstier) 

221 for partialPtcDataset in inputCovariances: 

222 # Ignore dummy datasets 

223 if partialPtcDataset.ptcFitType == 'DUMMY': 

224 continue 

225 for ampName in ampNames: 

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

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

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

229 else: 

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

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

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

233 else: 

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

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

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

237 else: 

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

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

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

241 else: 

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

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

244 datasetPtc.covariancesSqrtWeights[ampName].append( 

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

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

247 # rawMeans index 

248 for ampName in ampNames: 

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

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

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

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

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

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

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

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

257 datasetPtc.covariancesSqrtWeights[ampName])[index] 

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

259 # Fit the measured covariances vs mean signal to 

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

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

262 # signal (mu) curve using the EXPAPPROXIMATION model 

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

264 # get the flat pairs that are masked. The 

265 # points at these fluxes will also be masked when 

266 # calculating the other elements of the covariance 

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

268 

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

270 tempDatasetPtc = copy.copy(datasetPtc) 

271 tempDatasetPtc.ptcFitType = "EXPAPPROXIMATION" 

272 tempDatasetPtc = self.fitMeasurementsToModel(tempDatasetPtc) 

273 

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

275 # previous fit. 

276 for ampName in datasetPtc.ampNames: 

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

278 datasetPtc.fitType = "FULLCOVARIANCE" 

279 datasetPtc = self.fitMeasurementsToModel(datasetPtc) 

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

281 # ("EXPAPPROXIMATION", "POLYNOMIAL") 

282 else: 

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

284 # approximation (Eq. 16). Fill up 

285 # PhotonTransferCurveDataset object. 

286 datasetPtc = self.fitMeasurementsToModel(datasetPtc) 

287 if inputExpList is not None: 

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

289 detector = inputExpList[0].getDetector() 

290 else: 

291 detector = None 

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

293 

294 return pipeBase.Struct( 

295 outputPtcDataset=datasetPtc, 

296 ) 

297 

298 def fitMeasurementsToModel(self, dataset): 

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

300 polynomial or one of the models in Astier+19 

301 (Eq. 16 or Eq.20). 

302 

303 Parameters 

304 ---------- 

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

306 The dataset containing information such as the means, 

307 (co)variances, and exposure times. 

308 

309 Returns 

310 ------- 

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

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

313 it has been modified to include information such as the 

314 fit vectors and the fit parameters. See the class 

315 `PhotonTransferCurveDatase`. 

316 """ 

317 fitType = dataset.ptcFitType 

318 if fitType in ["FULLCOVARIANCE", ]: 

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

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

321 # with variance = Cov_00 

322 dataset = self.fitDataFullCovariance(dataset) 

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

324 # The PTC is technically defined as variance vs signal 

325 dataset = self.fitPtc(dataset) 

326 else: 

327 raise RuntimeError( 

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

329 "'POLYNOMIAL', 'EXPAPPROXIMATION', or 'FULLCOVARIANCE'" 

330 ) 

331 

332 return dataset 

333 

334 def fitDataFullCovariance(self, dataset): 

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

336 Astier+19 (Eq. 20). 

337 

338 Parameters 

339 ---------- 

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

341 The dataset containing information such as the means, 

342 (co)variances, and exposure times. 

343 

344 Returns 

345 ------- 

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

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

348 it has been modified to include information such as the 

349 fit vectors and the fit parameters. See the class 

350 `PhotonTransferCurveDatase`. 

351 

352 Notes 

353 ----- 

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

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

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

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

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

359 gain, units: e/ADU 

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

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

362 

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

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

365 maximum lag considered for the covariances calculation, and the 

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

367 have r^2 fewer entries. 

368 """ 

369 matrixSide = self.config.maximumRangeCovariancesAstier 

370 lenParams = matrixSide*matrixSide 

371 

372 for ampName in dataset.ampNames: 

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

374 # Not used when ptcFitType is 'FULLCOVARIANCE' 

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

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

377 dataset.ptcFitChiSq[ampName] = np.nan 

378 

379 if ampName in dataset.badAmps: 

380 # Bad amp 

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

382 # with astropy.Table works. 

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

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

385 dataset.covariancesModel[ampName] = listNanMatrix 

386 dataset.covariancesSqrtWeights[ampName] = listNanMatrix 

387 dataset.aMatrix[ampName] = nanMatrix 

388 dataset.bMatrix[ampName] = nanMatrix 

389 dataset.covariancesModelNoB[ampName] = listNanMatrix 

390 dataset.aMatrixNoB[ampName] = nanMatrix 

391 

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

393 dataset.gain[ampName] = np.nan 

394 dataset.gainErr[ampName] = np.nan 

395 dataset.noise[ampName] = np.nan 

396 dataset.noiseErr[ampName] = np.nan 

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

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

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

400 continue 

401 

402 muAtAmp = dataset.rawMeans[ampName] 

403 maskAtAmp = dataset.expIdMask[ampName] 

404 if len(maskAtAmp) == 0: 

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

406 

407 muAtAmp = muAtAmp[maskAtAmp] 

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

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

410 

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

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

413 

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

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

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

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

418 'fullModelNoB': self.funcFullCovarianceModelNoB} 

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

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

421 for key in functionsDict: 

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

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

424 weightsY=covSqrtWeightsAtAmp.flatten()) 

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

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

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

428 gain = params[-1] 

429 

430 fitResults[key]['a'] = a 

431 fitResults[key]['c'] = c 

432 fitResults[key]['noise'] = noise 

433 fitResults[key]['gain'] = gain 

434 fitResults[key]['paramsErr'] = paramsErr 

435 

436 # Put the information in the PTC dataset 

437 

438 # Not used when ptcFitType is 'FULLCOVARIANCE' 

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

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

441 dataset.ptcFitChiSq[ampName] = np.nan 

442 

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

444 # dataset.expIdMask is already full 

445 dataset.covariances[ampName] = covAtAmp 

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

447 fitResults['fullModel']['a'], 

448 fitResults['fullModel']['c'], 

449 fitResults['fullModel']['noise'], 

450 fitResults['fullModel']['gain']) 

451 dataset.covariancesSqrtWeights[ampName] = covSqrtWeightsAtAmp 

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

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

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

455 fitResults['fullModelNoB']['a'], 

456 fitResults['fullModelNoB']['c'], 

457 fitResults['fullModelNoB']['noise'], 

458 fitResults['fullModelNoB']['gain'], 

459 setBtoZero=True) 

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

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

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

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

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

465 dataset.noise[ampName] = readoutNoise 

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

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

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

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

470 dataset.finalMeans[ampName] = muAtAmp 

471 

472 return dataset 

473 

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

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

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

477 of Astier+19. 

478 

479 Parameters 

480 ---------- 

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

482 Signal `mu` (ADU) 

483 

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 

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

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

491 

492 Returns 

493 ------- 

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

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

496 

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

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

499 

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

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

502 

503 gain : `float` 

504 Amplifier gain (e/ADU) 

505 """ 

506 matrixSide = self.config.maximumRangeCovariancesAstier 

507 

508 # Initialize fit parameters 

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

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

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

512 gain = 1. 

513 

514 # iterate the fit to account for higher orders 

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

516 # stop when it increases 

517 oldChi2 = 1e30 

518 for _ in range(5): 

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

520 # loop on lags 

521 for i in range(matrixSide): 

522 for j in range(matrixSide): 

523 # fit a parabola for a given lag 

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

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

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

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

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

529 if(i + j == 0): 

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

531 weightedRes = (model - cov)*sqrtW 

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

533 if chi2 > oldChi2: 

534 break 

535 oldChi2 = chi2 

536 

537 return a, c, noise, gain 

538 

539 def funcFullCovarianceModel(self, params, x): 

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

541 Astier+19. 

542 

543 Parameters 

544 ---------- 

545 params : `list` 

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

547 gain (e/ADU). 

548 

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

550 Signal `mu` (ADU) 

551 

552 Returns 

553 ------- 

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

555 Covariance matrix. 

556 """ 

557 matrixSide = self.config.maximumRangeCovariancesAstier 

558 lenParams = matrixSide*matrixSide 

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

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

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

562 gain = params[-1] 

563 

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

565 

566 def funcFullCovarianceModelNoB(self, params, x): 

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

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

569 

570 Parameters 

571 ---------- 

572 params : `list` 

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

574 gain (e/ADU). 

575 

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

577 Signal mu (ADU) 

578 

579 Returns 

580 ------- 

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

582 Covariance matrix. 

583 """ 

584 matrixSide = self.config.maximumRangeCovariancesAstier 

585 lenParams = matrixSide*matrixSide 

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

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

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

589 gain = params[-1] 

590 

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

592 

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

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

595 

596 Parameters 

597 ---------- 

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

599 List of mean signals. 

600 

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

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

603 

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

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

606 

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

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

609 

610 gain : `float` 

611 Amplifier gain (e/ADU) 

612 

613 setBtoZero=False : `bool`, optional 

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

615 

616 Returns 

617 ------- 

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

619 Covariances model. 

620 

621 Notes 

622 ----- 

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

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

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

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

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

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

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

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

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

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

633 gain, units: e/ADU 

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

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

636 """ 

637 matrixSide = self.config.maximumRangeCovariancesAstier 

638 sa = (matrixSide, matrixSide) 

639 # pad a with zeros and symmetrize 

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

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

642 aSym = symmetrize(aEnlarged) 

643 # pad c with zeros and symmetrize 

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

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

646 cSym = symmetrize(cEnlarged) 

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

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

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

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

651 

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

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

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

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

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

657 

658 # assumes that mu is 1d 

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

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

661 # term, that is absent for now. 

662 if setBtoZero: 

663 c1 = np.zeros_like(c1) 

664 ac = np.zeros_like(ac) 

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

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

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

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

669 

670 return covModel 

671 

672 # EXPAPPROXIMATION and POLYNOMIAL fit methods 

673 @staticmethod 

674 def _initialParsForPolynomial(order): 

675 assert(order >= 2) 

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

677 pars[0] = 10 

678 pars[1] = 1 

679 pars[2:] = 0.0001 

680 return pars 

681 

682 @staticmethod 

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

684 if not len(lowers): 

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

686 if not len(uppers): 

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

688 lowers[1] = 0 # no negative gains 

689 return (lowers, uppers) 

690 

691 @staticmethod 

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

693 if not len(lowers): 

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

695 if not len(uppers): 

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

697 return (lowers, uppers) 

698 

699 @staticmethod 

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

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

702 

703 Parameters 

704 ---------- 

705 means : `numpy.array` 

706 Input array with mean signal values. 

707 variances : `numpy.array` 

708 Input array with variances at each mean value. 

709 minVarPivotSearch : `float` 

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

711 of decreasing variance should be sought. 

712 consecutivePointsVarDecreases : `int` 

713 Required number of consecutive points/fluxes 

714 in the PTC where the variance 

715 decreases in order to find a first 

716 estimate of the PTC turn-off. 

717 

718 Returns 

719 ------ 

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

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

722 points. 

723 

724 Notes 

725 ----- 

726 Eliminate points beyond which the variance decreases. 

727 """ 

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

729 # Variances are sorted and should monotonically increase 

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

731 if len(pivotList) > 0: 

732 # For small values, sometimes the variance decreases slightly 

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

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

735 # Require that the varince decreases during 

736 # consecutivePointsVarDecreases 

737 # consecutive points. This will give a first 

738 # estimate of the PTC turn-off, which 

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

740 if len(pivotList) > 1: 

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

742 # each value in pivotList. The lambda function subtracts 

743 # each value from the index. 

744 # groupby groups elements by equal key value. 

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

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

747 # Form groups of consecute values from pivotList 

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

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

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

751 # Find the first group of consecutive numbers when 

752 # variance decreases. 

753 if len(group) >= consecutivePointsVarDecreases: 

754 pivotIndex = np.min(group) 

755 goodPoints[pivotIndex+1:] = False 

756 break 

757 

758 return goodPoints 

759 

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

761 """""" 

762 array = np.array(array) 

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

764 if nBad == 0: 

765 return array 

766 

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

768 if len(index): 

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

770 self.log.warning(msg) 

771 

772 array[index] = substituteValue 

773 

774 return array 

775 

776 def fitPtc(self, dataset): 

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

778 Astier+19 approximation (Eq. 16). 

779 

780 Fit the photon transfer curve with either a polynomial of 

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

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

783 

784 Sigma clipping is performed iteratively for the fit, as 

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

786 than `config.initialNonLinearityExclusionThreshold` away 

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

788 because the photon transfer curve turns over catastrophically 

789 at very high flux (because saturation 

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

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

792 to perform the sigma-clipping. 

793 

794 Parameters 

795 ---------- 

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

797 The dataset containing the means, variances and 

798 exposure times. 

799 

800 Returns 

801 ------- 

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

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

804 it has been modified to include information such as the 

805 fit vectors and the fit parameters. See the class 

806 `PhotonTransferCurveDatase`. 

807 

808 Raises 

809 ------ 

810 RuntimeError: 

811 Raises if dataset.ptcFitType is None or empty. 

812 """ 

813 if dataset.ptcFitType: 

814 ptcFitType = dataset.ptcFitType 

815 else: 

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

817 matrixSide = self.config.maximumRangeCovariancesAstier 

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

819 nanMatrix[:] = np.nan 

820 

821 for amp in dataset.ampNames: 

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

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

824 listNanMatrix[:] = np.nan 

825 

826 dataset.covariancesModel[amp] = listNanMatrix 

827 dataset.aMatrix[amp] = nanMatrix 

828 dataset.bMatrix[amp] = nanMatrix 

829 dataset.covariancesModelNoB[amp] = listNanMatrix 

830 dataset.aMatrixNoB[amp] = nanMatrix 

831 

832 def errFunc(p, x, y): 

833 return ptcFunc(p, x) - y 

834 

835 sigmaCutPtcOutliers = self.config.sigmaCutPtcOutliers 

836 maxIterationsPtcOutliers = self.config.maxIterationsPtcOutliers 

837 

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

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

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

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

842 varVecOriginal = self._makeZeroSafe(varVecOriginal) 

843 

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

845 # consecutive signal levels 

846 goodPoints = self._getInitialGoodPoints(meanVecOriginal, varVecOriginal, 

847 self.config.minVarPivotSearch, 

848 self.config.consecutivePointsVarDecreases) 

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

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

851 

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

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

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

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

856 self.log.warning(msg) 

857 # Fill entries with NaNs 

858 self.fillBadAmp(dataset, ptcFitType, ampName) 

859 continue 

860 

861 mask = goodPoints 

862 

863 if ptcFitType == 'EXPAPPROXIMATION': 

864 ptcFunc = funcAstier 

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

866 # lowers and uppers obtained from BOT data studies by 

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

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

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

870 if ptcFitType == 'POLYNOMIAL': 

871 ptcFunc = funcPolynomial 

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

873 bounds = self._boundsForPolynomial(parsIniPtc) 

874 

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

876 # This further process of outlier rejection be skipped 

877 # if self.config.maxIterationsPtcOutliers = 0. 

878 # We already did some initial outlier rejection above in 

879 # self._getInitialGoodPoints. 

880 count = 1 

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

882 pars = parsIniPtc 

883 while count <= maxIterationsPtcOutliers: 

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

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

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

887 meanTempVec = meanVecOriginal[mask] 

888 varTempVec = varVecOriginal[mask] 

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

890 pars = res.x 

891 

892 # change this to the original from the temp because 

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

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

895 # same length for broadcasting 

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

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

898 mask = mask & newMask 

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

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

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

902 self.log.warning(msg) 

903 # Fill entries with NaNs 

904 self.fillBadAmp(dataset, ptcFitType, ampName) 

905 break 

906 nDroppedTotal = Counter(mask)[False] 

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

908 count, nDroppedTotal, ampName) 

909 count += 1 

910 # objects should never shrink 

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

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

913 continue 

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

915 # store the final mask 

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

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

918 else: 

919 dataset.expIdMask[ampName] = mask 

920 parsIniPtc = pars 

921 meanVecFinal = meanVecOriginal[mask] 

922 varVecFinal = varVecOriginal[mask] 

923 

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

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

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

927 

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

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

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

931 self.log.warning(msg) 

932 # Fill entries with NaNs 

933 self.fillBadAmp(dataset, ptcFitType, ampName) 

934 continue 

935 # Fit the PTC 

936 if self.config.doFitBootstrap: 

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

938 varVecFinal, ptcFunc, 

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

940 else: 

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

942 varVecFinal, ptcFunc, 

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

944 dataset.ptcFitPars[ampName] = parsFit 

945 dataset.ptcFitParsError[ampName] = parsFitErr 

946 dataset.ptcFitChiSq[ampName] = reducedChiSqPtc 

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

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

949 # mask may vary per amp). 

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

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

952 constant_values=np.nan) 

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

954 'constant', constant_values=np.nan) 

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

956 constant_values=np.nan) 

957 if ptcFitType == 'EXPAPPROXIMATION': 

958 ptcGain = parsFit[1] 

959 ptcGainErr = parsFitErr[1] 

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

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

962 if ptcFitType == 'POLYNOMIAL': 

963 ptcGain = 1./parsFit[1] 

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

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

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

967 dataset.gain[ampName] = ptcGain 

968 dataset.gainErr[ampName] = ptcGainErr 

969 dataset.noise[ampName] = ptcNoise 

970 dataset.noiseErr[ampName] = ptcNoiseErr 

971 

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

973 dataset.ptcFitType = ptcFitType 

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

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

976 

977 return dataset 

978 

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

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

981 good points. 

982 

983 Parameters 

984 ---------- 

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

986 The dataset containing the means, variances and 

987 exposure times. 

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

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

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

991 ampName : `str` 

992 Amplifier name. 

993 """ 

994 dataset.badAmps.append(ampName) 

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

996 dataset.gain[ampName] = np.nan 

997 dataset.gainErr[ampName] = np.nan 

998 dataset.noise[ampName] = np.nan 

999 dataset.noiseErr[ampName] = np.nan 

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

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

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

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

1004 dataset.ptcFitChiSq[ampName] = np.nan 

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

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

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

1008 

1009 return