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

320 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-01 03:06 -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 lmfit.models import GaussianModel 

24import scipy.stats 

25import warnings 

26 

27import lsst.afw.math as afwMath 

28import lsst.pex.config as pexConfig 

29import lsst.pipe.base as pipeBase 

30from lsst.cp.pipe.utils import (arrangeFlatsByExpTime, arrangeFlatsByExpId, 

31 arrangeFlatsByExpFlux, sigmaClipCorrection, 

32 CovFastFourierTransform) 

33 

34import lsst.pipe.base.connectionTypes as cT 

35 

36from lsst.ip.isr import PhotonTransferCurveDataset 

37from lsst.ip.isr import IsrTask 

38 

39__all__ = ['PhotonTransferCurveExtractConfig', 'PhotonTransferCurveExtractTask'] 

40 

41 

42class PhotonTransferCurveExtractConnections(pipeBase.PipelineTaskConnections, 

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

44 

45 inputExp = cT.Input( 

46 name="ptcInputExposurePairs", 

47 doc="Input post-ISR processed exposure pairs (flats) to" 

48 "measure covariances from.", 

49 storageClass="Exposure", 

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

51 multiple=True, 

52 deferLoad=True, 

53 ) 

54 taskMetadata = cT.Input( 

55 name="isr_metadata", 

56 doc="Input task metadata to extract statistics from.", 

57 storageClass="TaskMetadata", 

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

59 multiple=True, 

60 ) 

61 outputCovariances = cT.Output( 

62 name="ptcCovariances", 

63 doc="Extracted flat (co)variances.", 

64 storageClass="PhotonTransferCurveDataset", 

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

66 isCalibration=True, 

67 multiple=True, 

68 ) 

69 

70 

71class PhotonTransferCurveExtractConfig(pipeBase.PipelineTaskConfig, 

72 pipelineConnections=PhotonTransferCurveExtractConnections): 

73 """Configuration for the measurement of covariances from flats. 

74 """ 

75 matchExposuresType = pexConfig.ChoiceField( 

76 dtype=str, 

77 doc="Match input exposures by time, flux, or expId", 

78 default='TIME', 

79 allowed={ 

80 "TIME": "Match exposures by exposure time.", 

81 "FLUX": "Match exposures by target flux. Use header keyword" 

82 " in matchExposuresByFluxKeyword to find the flux.", 

83 "EXPID": "Match exposures by exposure ID." 

84 } 

85 ) 

86 matchExposuresByFluxKeyword = pexConfig.Field( 

87 dtype=str, 

88 doc="Header keyword for flux if matchExposuresType is FLUX.", 

89 default='CCOBFLUX', 

90 ) 

91 maximumRangeCovariancesAstier = pexConfig.Field( 

92 dtype=int, 

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

94 default=8, 

95 ) 

96 binSize = pexConfig.Field( 

97 dtype=int, 

98 doc="Bin the image by this factor in both dimensions.", 

99 default=1, 

100 ) 

101 minMeanSignal = pexConfig.DictField( 

102 keytype=str, 

103 itemtype=float, 

104 doc="Minimum values (inclusive) of mean signal (in ADU) per amp to use." 

105 " The same cut is applied to all amps if this parameter [`dict`] is passed as " 

106 " {'ALL_AMPS': value}", 

107 default={'ALL_AMPS': 0.0}, 

108 deprecated="This config has been moved to cpSolvePtcTask, and will be removed after v26.", 

109 ) 

110 maxMeanSignal = pexConfig.DictField( 

111 keytype=str, 

112 itemtype=float, 

113 doc="Maximum values (inclusive) of mean signal (in ADU) below which to consider, per amp." 

114 " The same cut is applied to all amps if this dictionary is of the form" 

115 " {'ALL_AMPS': value}", 

116 default={'ALL_AMPS': 1e6}, 

117 deprecated="This config has been moved to cpSolvePtcTask, and will be removed after v26.", 

118 ) 

119 maskNameList = pexConfig.ListField( 

120 dtype=str, 

121 doc="Mask list to exclude from statistics calculations.", 

122 default=['SUSPECT', 'BAD', 'NO_DATA', 'SAT'], 

123 ) 

124 nSigmaClipPtc = pexConfig.Field( 

125 dtype=float, 

126 doc="Sigma cut for afwMath.StatisticsControl()", 

127 default=5.5, 

128 ) 

129 nIterSigmaClipPtc = pexConfig.Field( 

130 dtype=int, 

131 doc="Number of sigma-clipping iterations for afwMath.StatisticsControl()", 

132 default=3, 

133 ) 

134 minNumberGoodPixelsForCovariance = pexConfig.Field( 

135 dtype=int, 

136 doc="Minimum number of acceptable good pixels per amp to calculate the covariances (via FFT or" 

137 " direclty).", 

138 default=10000, 

139 ) 

140 thresholdDiffAfwVarVsCov00 = pexConfig.Field( 

141 dtype=float, 

142 doc="If the absolute fractional differece between afwMath.VARIANCECLIP and Cov00 " 

143 "for a region of a difference image is greater than this threshold (percentage), " 

144 "a warning will be issued.", 

145 default=1., 

146 ) 

147 detectorMeasurementRegion = pexConfig.ChoiceField( 

148 dtype=str, 

149 doc="Region of each exposure where to perform the calculations (amplifier or full image).", 

150 default='AMP', 

151 allowed={ 

152 "AMP": "Amplifier of the detector.", 

153 "FULL": "Full image." 

154 } 

155 ) 

156 numEdgeSuspect = pexConfig.Field( 

157 dtype=int, 

158 doc="Number of edge pixels to be flagged as untrustworthy.", 

159 default=0, 

160 ) 

161 edgeMaskLevel = pexConfig.ChoiceField( 

162 dtype=str, 

163 doc="Mask edge pixels in which coordinate frame: DETECTOR or AMP?", 

164 default="DETECTOR", 

165 allowed={ 

166 'DETECTOR': 'Mask only the edges of the full detector.', 

167 'AMP': 'Mask edges of each amplifier.', 

168 }, 

169 ) 

170 doGain = pexConfig.Field( 

171 dtype=bool, 

172 doc="Calculate a gain per input flat pair.", 

173 default=True, 

174 ) 

175 gainCorrectionType = pexConfig.ChoiceField( 

176 dtype=str, 

177 doc="Correction type for the gain.", 

178 default='FULL', 

179 allowed={ 

180 'NONE': 'No correction.', 

181 'SIMPLE': 'First order correction.', 

182 'FULL': 'Second order correction.' 

183 } 

184 ) 

185 ksHistNBins = pexConfig.Field( 

186 dtype=int, 

187 doc="Number of bins for the KS test histogram.", 

188 default=100, 

189 ) 

190 ksHistLimitMultiplier = pexConfig.Field( 

191 dtype=float, 

192 doc="Number of sigma (as predicted from the mean value) to compute KS test histogram.", 

193 default=8.0, 

194 ) 

195 ksHistMinDataValues = pexConfig.Field( 

196 dtype=int, 

197 doc="Minimum number of good data values to compute KS test histogram.", 

198 default=100, 

199 ) 

200 auxiliaryHeaderKeys = pexConfig.ListField( 

201 dtype=str, 

202 doc="Auxiliary header keys to store with the PTC dataset.", 

203 default=[], 

204 ) 

205 

206 

207class PhotonTransferCurveExtractTask(pipeBase.PipelineTask): 

208 """Task to measure covariances from flat fields. 

209 

210 This task receives as input a list of flat-field images 

211 (flats), and sorts these flats in pairs taken at the 

212 same time (the task will raise if there is one one flat 

213 at a given exposure time, and it will discard extra flats if 

214 there are more than two per exposure time). This task measures 

215 the mean, variance, and covariances from a region (e.g., 

216 an amplifier) of the difference image of the two flats with 

217 the same exposure time (alternatively, all input images could have 

218 the same exposure time but their flux changed). 

219 

220 The variance is calculated via afwMath, and the covariance 

221 via the methods in Astier+19 (appendix A). In theory, 

222 var = covariance[0,0]. This should be validated, and in the 

223 future, we may decide to just keep one (covariance). 

224 At this moment, if the two values differ by more than the value 

225 of `thresholdDiffAfwVarVsCov00` (default: 1%), a warning will 

226 be issued. 

227 

228 The measured covariances at a given exposure time (along with 

229 other quantities such as the mean) are stored in a PTC dataset 

230 object (`~lsst.ip.isr.PhotonTransferCurveDataset`), which gets 

231 partially filled at this stage (the remainder of the attributes 

232 of the dataset will be filled after running the second task of 

233 the PTC-measurement pipeline, `~PhotonTransferCurveSolveTask`). 

234 

235 The number of partially-filled 

236 `~lsst.ip.isr.PhotonTransferCurveDataset` objects will be less 

237 than the number of input exposures because the task combines 

238 input flats in pairs. However, it is required at this moment 

239 that the number of input dimensions matches 

240 bijectively the number of output dimensions. Therefore, a number 

241 of "dummy" PTC datasets are inserted in the output list. This 

242 output list will then be used as input of the next task in the 

243 PTC-measurement pipeline, `PhotonTransferCurveSolveTask`, 

244 which will assemble the multiple `PhotonTransferCurveDataset` 

245 objects into a single one in order to fit the measured covariances 

246 as a function of flux to one of three models 

247 (see `PhotonTransferCurveSolveTask` for details). 

248 

249 Reference: Astier+19: "The Shape of the Photon Transfer Curve of CCD 

250 sensors", arXiv:1905.08677. 

251 """ 

252 

253 ConfigClass = PhotonTransferCurveExtractConfig 

254 _DefaultName = 'cpPtcExtract' 

255 

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

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

258 

259 Parameters 

260 ---------- 

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

262 Butler to operate on. 

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

264 Input data refs to load. 

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

266 Output data refs to persist. 

267 """ 

268 inputs = butlerQC.get(inputRefs) 

269 # Ids of input list of exposure references 

270 # (deferLoad=True in the input connections) 

271 inputs['inputDims'] = [expRef.datasetRef.dataId['exposure'] for expRef in inputRefs.inputExp] 

272 

273 # Dictionary, keyed by expTime (or expFlux or expId), with tuples 

274 # containing flat exposures and their IDs. 

275 matchType = self.config.matchExposuresType 

276 if matchType == 'TIME': 

277 inputs['inputExp'] = arrangeFlatsByExpTime(inputs['inputExp'], inputs['inputDims']) 

278 elif matchType == 'FLUX': 

279 inputs['inputExp'] = arrangeFlatsByExpFlux(inputs['inputExp'], inputs['inputDims'], 

280 self.config.matchExposuresByFluxKeyword) 

281 else: 

282 inputs['inputExp'] = arrangeFlatsByExpId(inputs['inputExp'], inputs['inputDims']) 

283 

284 outputs = self.run(**inputs) 

285 outputs = self._guaranteeOutputs(inputs['inputDims'], outputs, outputRefs) 

286 butlerQC.put(outputs, outputRefs) 

287 

288 def _guaranteeOutputs(self, inputDims, outputs, outputRefs): 

289 """Ensure that all outputRefs have a matching output, and if they do 

290 not, fill the output with dummy PTC datasets. 

291 

292 Parameters 

293 ---------- 

294 inputDims : `dict` [`str`, `int`] 

295 Input exposure dimensions. 

296 outputs : `lsst.pipe.base.Struct` 

297 Outputs from the ``run`` method. Contains the entry: 

298 

299 ``outputCovariances`` 

300 Output PTC datasets (`list` [`lsst.ip.isr.IsrCalib`]) 

301 outputRefs : `~lsst.pipe.base.connections.OutputQuantizedConnection` 

302 Container with all of the outputs expected to be generated. 

303 

304 Returns 

305 ------- 

306 outputs : `lsst.pipe.base.Struct` 

307 Dummy dataset padded version of the input ``outputs`` with 

308 the same entries. 

309 """ 

310 newCovariances = [] 

311 for ref in outputRefs.outputCovariances: 

312 outputExpId = ref.dataId['exposure'] 

313 if outputExpId in inputDims: 

314 entry = inputDims.index(outputExpId) 

315 newCovariances.append(outputs.outputCovariances[entry]) 

316 else: 

317 newPtc = PhotonTransferCurveDataset(['no amp'], 'DUMMY', 1) 

318 newPtc.setAmpValuesPartialDataset('no amp') 

319 newCovariances.append(newPtc) 

320 return pipeBase.Struct(outputCovariances=newCovariances) 

321 

322 def run(self, inputExp, inputDims, taskMetadata): 

323 

324 """Measure covariances from difference of flat pairs 

325 

326 Parameters 

327 ---------- 

328 inputExp : `dict` [`float`, `list` 

329 [`~lsst.pipe.base.connections.DeferredDatasetRef`]] 

330 Dictionary that groups references to flat-field exposures that 

331 have the same exposure time (seconds), or that groups them 

332 sequentially by their exposure id. 

333 inputDims : `list` 

334 List of exposure IDs. 

335 taskMetadata : `list` [`lsst.pipe.base.TaskMetadata`] 

336 List of exposures metadata from ISR. 

337 

338 Returns 

339 ------- 

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

341 The resulting Struct contains: 

342 

343 ``outputCovariances`` 

344 A list containing the per-pair PTC measurements (`list` 

345 [`lsst.ip.isr.PhotonTransferCurveDataset`]) 

346 """ 

347 # inputExp.values() returns a view, which we turn into a list. We then 

348 # access the first exposure-ID tuple to get the detector. 

349 # The first "get()" retrieves the exposure from the exposure reference. 

350 detector = list(inputExp.values())[0][0][0].get(component='detector') 

351 detNum = detector.getId() 

352 amps = detector.getAmplifiers() 

353 ampNames = [amp.getName() for amp in amps] 

354 

355 # Each amp may have a different min and max ADU signal 

356 # specified in the config. 

357 maxMeanSignalDict = {ampName: 1e6 for ampName in ampNames} 

358 minMeanSignalDict = {ampName: 0.0 for ampName in ampNames} 

359 for ampName in ampNames: 

360 if 'ALL_AMPS' in self.config.maxMeanSignal: 

361 maxMeanSignalDict[ampName] = self.config.maxMeanSignal['ALL_AMPS'] 

362 elif ampName in self.config.maxMeanSignal: 

363 maxMeanSignalDict[ampName] = self.config.maxMeanSignal[ampName] 

364 

365 if 'ALL_AMPS' in self.config.minMeanSignal: 

366 minMeanSignalDict[ampName] = self.config.minMeanSignal['ALL_AMPS'] 

367 elif ampName in self.config.minMeanSignal: 

368 minMeanSignalDict[ampName] = self.config.minMeanSignal[ampName] 

369 # These are the column names for `tupleRows` below. 

370 tags = [('mu', '<f8'), ('afwVar', '<f8'), ('i', '<i8'), ('j', '<i8'), ('var', '<f8'), 

371 ('cov', '<f8'), ('npix', '<i8'), ('ext', '<i8'), ('expTime', '<f8'), ('ampName', '<U3')] 

372 # Create a dummy ptcDataset. Dummy datasets will be 

373 # used to ensure that the number of output and input 

374 # dimensions match. 

375 dummyPtcDataset = PhotonTransferCurveDataset(ampNames, 'DUMMY', 

376 self.config.maximumRangeCovariancesAstier) 

377 for ampName in ampNames: 

378 dummyPtcDataset.setAmpValuesPartialDataset(ampName) 

379 # Get read noise. Try from the exposure, then try 

380 # taskMetadata. This adds a get() for the exposures. 

381 readNoiseLists = {} 

382 for pairIndex, expRefs in inputExp.items(): 

383 # This yields an index (exposure_time, seq_num, or flux) 

384 # and a pair of references at that index. 

385 for expRef, expId in expRefs: 

386 # This yields an exposure ref and an exposureId. 

387 exposureMetadata = expRef.get(component="metadata") 

388 metadataIndex = inputDims.index(expId) 

389 thisTaskMetadata = taskMetadata[metadataIndex] 

390 

391 for ampName in ampNames: 

392 if ampName not in readNoiseLists: 

393 readNoiseLists[ampName] = [self.getReadNoise(exposureMetadata, 

394 thisTaskMetadata, ampName)] 

395 else: 

396 readNoiseLists[ampName].append(self.getReadNoise(exposureMetadata, 

397 thisTaskMetadata, ampName)) 

398 

399 readNoiseDict = {ampName: 0.0 for ampName in ampNames} 

400 for ampName in ampNames: 

401 # Take median read noise value 

402 readNoiseDict[ampName] = np.nanmedian(readNoiseLists[ampName]) 

403 

404 # Output list with PTC datasets. 

405 partialPtcDatasetList = [] 

406 # The number of output references needs to match that of input 

407 # references: initialize outputlist with dummy PTC datasets. 

408 for i in range(len(inputDims)): 

409 partialPtcDatasetList.append(dummyPtcDataset) 

410 

411 if self.config.numEdgeSuspect > 0: 

412 isrTask = IsrTask() 

413 self.log.info("Masking %d pixels from the edges of all %ss as SUSPECT.", 

414 self.config.numEdgeSuspect, self.config.edgeMaskLevel) 

415 

416 # Depending on the value of config.matchExposuresType 

417 # 'expTime' can stand for exposure time, flux, or ID. 

418 for expTime in inputExp: 

419 exposures = inputExp[expTime] 

420 if len(exposures) == 1: 

421 self.log.warning("Only one exposure found at %s %f. Dropping exposure %d.", 

422 self.config.matchExposuresType, expTime, exposures[0][1]) 

423 continue 

424 else: 

425 # Only use the first two exposures at expTime. Each 

426 # element is a tuple (exposure, expId) 

427 expRef1, expId1 = exposures[0] 

428 expRef2, expId2 = exposures[1] 

429 # use get() to obtain `lsst.afw.image.Exposure` 

430 exp1, exp2 = expRef1.get(), expRef2.get() 

431 

432 if len(exposures) > 2: 

433 self.log.warning("Already found 2 exposures at %s %f. Ignoring exposures: %s", 

434 self.config.matchExposuresType, expTime, 

435 ", ".join(str(i[1]) for i in exposures[2:])) 

436 # Mask pixels at the edge of the detector or of each amp 

437 if self.config.numEdgeSuspect > 0: 

438 isrTask.maskEdges(exp1, numEdgePixels=self.config.numEdgeSuspect, 

439 maskPlane="SUSPECT", level=self.config.edgeMaskLevel) 

440 isrTask.maskEdges(exp2, numEdgePixels=self.config.numEdgeSuspect, 

441 maskPlane="SUSPECT", level=self.config.edgeMaskLevel) 

442 

443 # Extract any metadata keys from the headers. 

444 auxDict = {} 

445 metadata = exp1.getMetadata() 

446 for key in self.config.auxiliaryHeaderKeys: 

447 if key not in metadata: 

448 self.log.warning( 

449 "Requested auxiliary keyword %s not found in exposure metadata for %d", 

450 key, 

451 expId1, 

452 ) 

453 value = np.nan 

454 else: 

455 value = metadata[key] 

456 

457 auxDict[key] = value 

458 

459 nAmpsNan = 0 

460 partialPtcDataset = PhotonTransferCurveDataset(ampNames, 'PARTIAL', 

461 self.config.maximumRangeCovariancesAstier) 

462 for ampNumber, amp in enumerate(detector): 

463 ampName = amp.getName() 

464 if self.config.detectorMeasurementRegion == 'AMP': 

465 region = amp.getBBox() 

466 elif self.config.detectorMeasurementRegion == 'FULL': 

467 region = None 

468 

469 # Get masked image regions, masking planes, statistic control 

470 # objects, and clipped means. Calculate once to reuse in 

471 # `measureMeanVarCov` and `getGainFromFlatPair`. 

472 im1Area, im2Area, imStatsCtrl, mu1, mu2 = self.getImageAreasMasksStats(exp1, exp2, 

473 region=region) 

474 

475 # We demand that both mu1 and mu2 be finite and greater than 0. 

476 if not np.isfinite(mu1) or not np.isfinite(mu2) \ 

477 or ((np.nan_to_num(mu1) + np.nan_to_num(mu2)/2.) <= 0.0): 

478 self.log.warning( 

479 "Illegal mean value(s) detected for amp %s on exposure pair %d/%d", 

480 ampName, 

481 expId1, 

482 expId2, 

483 ) 

484 partialPtcDataset.setAmpValuesPartialDataset( 

485 ampName, 

486 inputExpIdPair=(expId1, expId2), 

487 rawExpTime=expTime, 

488 expIdMask=False, 

489 ) 

490 continue 

491 

492 # `measureMeanVarCov` is the function that measures 

493 # the variance and covariances from a region of 

494 # the difference image of two flats at the same 

495 # exposure time. The variable `covAstier` that is 

496 # returned is of the form: 

497 # [(i, j, var (cov[0,0]), cov, npix) for (i,j) in 

498 # {maxLag, maxLag}^2]. 

499 muDiff, varDiff, covAstier = self.measureMeanVarCov(im1Area, im2Area, imStatsCtrl, mu1, mu2) 

500 # Estimate the gain from the flat pair 

501 if self.config.doGain: 

502 gain = self.getGainFromFlatPair(im1Area, im2Area, imStatsCtrl, mu1, mu2, 

503 correctionType=self.config.gainCorrectionType, 

504 readNoise=readNoiseDict[ampName]) 

505 else: 

506 gain = np.nan 

507 

508 # Correction factor for bias introduced by sigma 

509 # clipping. 

510 # Function returns 1/sqrt(varFactor), so it needs 

511 # to be squared. varDiff is calculated via 

512 # afwMath.VARIANCECLIP. 

513 varFactor = sigmaClipCorrection(self.config.nSigmaClipPtc)**2 

514 varDiff *= varFactor 

515 

516 expIdMask = True 

517 # Mask data point at this mean signal level if 

518 # the signal, variance, or covariance calculations 

519 # from `measureMeanVarCov` resulted in NaNs. 

520 if np.isnan(muDiff) or np.isnan(varDiff) or (covAstier is None): 

521 self.log.warning("NaN mean or var, or None cov in amp %s in exposure pair %d, %d of " 

522 "detector %d.", ampName, expId1, expId2, detNum) 

523 nAmpsNan += 1 

524 expIdMask = False 

525 covArray = np.full((1, self.config.maximumRangeCovariancesAstier, 

526 self.config.maximumRangeCovariancesAstier), np.nan) 

527 covSqrtWeights = np.full_like(covArray, np.nan) 

528 

529 # Mask data point if it is outside of the 

530 # specified mean signal range. 

531 if (muDiff <= minMeanSignalDict[ampName]) or (muDiff >= maxMeanSignalDict[ampName]): 

532 expIdMask = False 

533 

534 if covAstier is not None: 

535 # Turn the tuples with the measured information 

536 # into covariance arrays. 

537 # covrow: (i, j, var (cov[0,0]), cov, npix) 

538 tupleRows = [(muDiff, varDiff) + covRow + (ampNumber, expTime, 

539 ampName) for covRow in covAstier] 

540 tempStructArray = np.array(tupleRows, dtype=tags) 

541 

542 covArray, vcov, _ = self.makeCovArray(tempStructArray, 

543 self.config.maximumRangeCovariancesAstier) 

544 

545 # The returned covArray should only have 1 entry; 

546 # raise if this is not the case. 

547 if covArray.shape[0] != 1: 

548 raise RuntimeError("Serious programming error in covArray shape.") 

549 

550 covSqrtWeights = np.nan_to_num(1./np.sqrt(vcov)) 

551 

552 # Correct covArray for sigma clipping: 

553 # 1) Apply varFactor twice for the whole covariance matrix 

554 covArray *= varFactor**2 

555 # 2) But, only once for the variance element of the 

556 # matrix, covArray[0, 0, 0] (so divide one factor out). 

557 # (the first 0 is because this is a 3D array for insertion into 

558 # the combined dataset). 

559 covArray[0, 0, 0] /= varFactor 

560 

561 if expIdMask: 

562 # Run the Gaussian histogram only if this is a legal 

563 # amplifier. 

564 histVar, histChi2Dof, kspValue = self.computeGaussianHistogramParameters( 

565 im1Area, 

566 im2Area, 

567 imStatsCtrl, 

568 mu1, 

569 mu2, 

570 ) 

571 else: 

572 histVar = np.nan 

573 histChi2Dof = np.nan 

574 kspValue = 0.0 

575 

576 partialPtcDataset.setAmpValuesPartialDataset( 

577 ampName, 

578 inputExpIdPair=(expId1, expId2), 

579 rawExpTime=expTime, 

580 rawMean=muDiff, 

581 rawVar=varDiff, 

582 expIdMask=expIdMask, 

583 covariance=covArray[0, :, :], 

584 covSqrtWeights=covSqrtWeights[0, :, :], 

585 gain=gain, 

586 noise=readNoiseDict[ampName], 

587 histVar=histVar, 

588 histChi2Dof=histChi2Dof, 

589 kspValue=kspValue, 

590 ) 

591 

592 partialPtcDataset.setAuxValuesPartialDataset(auxDict) 

593 

594 # Use location of exp1 to save PTC dataset from (exp1, exp2) pair. 

595 # Below, np.where(expId1 == np.array(inputDims)) returns a tuple 

596 # with a single-element array, so [0][0] 

597 # is necessary to extract the required index. 

598 datasetIndex = np.where(expId1 == np.array(inputDims))[0][0] 

599 # `partialPtcDatasetList` is a list of 

600 # `PhotonTransferCurveDataset` objects. Some of them 

601 # will be dummy datasets (to match length of input 

602 # and output references), and the rest will have 

603 # datasets with the mean signal, variance, and 

604 # covariance measurements at a given exposure 

605 # time. The next ppart of the PTC-measurement 

606 # pipeline, `solve`, will take this list as input, 

607 # and assemble the measurements in the datasets 

608 # in an addecuate manner for fitting a PTC 

609 # model. 

610 partialPtcDataset.updateMetadataFromExposures([exp1, exp2]) 

611 partialPtcDataset.updateMetadata(setDate=True, detector=detector) 

612 partialPtcDatasetList[datasetIndex] = partialPtcDataset 

613 

614 if nAmpsNan == len(ampNames): 

615 msg = f"NaN mean in all amps of exposure pair {expId1}, {expId2} of detector {detNum}." 

616 self.log.warning(msg) 

617 

618 return pipeBase.Struct( 

619 outputCovariances=partialPtcDatasetList, 

620 ) 

621 

622 def makeCovArray(self, inputTuple, maxRangeFromTuple): 

623 """Make covariances array from tuple. 

624 

625 Parameters 

626 ---------- 

627 inputTuple : `numpy.ndarray` 

628 Structured array with rows with at least 

629 (mu, afwVar, cov, var, i, j, npix), where: 

630 mu : `float` 

631 0.5*(m1 + m2), where mu1 is the mean value of flat1 

632 and mu2 is the mean value of flat2. 

633 afwVar : `float` 

634 Variance of difference flat, calculated with afw. 

635 cov : `float` 

636 Covariance value at lag(i, j) 

637 var : `float` 

638 Variance(covariance value at lag(0, 0)) 

639 i : `int` 

640 Lag in dimension "x". 

641 j : `int` 

642 Lag in dimension "y". 

643 npix : `int` 

644 Number of pixels used for covariance calculation. 

645 maxRangeFromTuple : `int` 

646 Maximum range to select from tuple. 

647 

648 Returns 

649 ------- 

650 cov : `numpy.array` 

651 Covariance arrays, indexed by mean signal mu. 

652 vCov : `numpy.array` 

653 Variance of the [co]variance arrays, indexed by mean signal mu. 

654 muVals : `numpy.array` 

655 List of mean signal values. 

656 """ 

657 if maxRangeFromTuple is not None: 

658 cut = (inputTuple['i'] < maxRangeFromTuple) & (inputTuple['j'] < maxRangeFromTuple) 

659 cutTuple = inputTuple[cut] 

660 else: 

661 cutTuple = inputTuple 

662 # increasing mu order, so that we can group measurements with the 

663 # same mu 

664 muTemp = cutTuple['mu'] 

665 ind = np.argsort(muTemp) 

666 

667 cutTuple = cutTuple[ind] 

668 # should group measurements on the same image pairs(same average) 

669 mu = cutTuple['mu'] 

670 xx = np.hstack(([mu[0]], mu)) 

671 delta = xx[1:] - xx[:-1] 

672 steps, = np.where(delta > 0) 

673 ind = np.zeros_like(mu, dtype=int) 

674 ind[steps] = 1 

675 ind = np.cumsum(ind) # this acts as an image pair index. 

676 # now fill the 3-d cov array(and variance) 

677 muVals = np.array(np.unique(mu)) 

678 i = cutTuple['i'].astype(int) 

679 j = cutTuple['j'].astype(int) 

680 c = 0.5*cutTuple['cov'] 

681 n = cutTuple['npix'] 

682 v = 0.5*cutTuple['var'] 

683 # book and fill 

684 cov = np.ndarray((len(muVals), np.max(i)+1, np.max(j)+1)) 

685 var = np.zeros_like(cov) 

686 cov[ind, i, j] = c 

687 var[ind, i, j] = v**2/n 

688 var[:, 0, 0] *= 2 # var(v) = 2*v**2/N 

689 

690 return cov, var, muVals 

691 

692 def measureMeanVarCov(self, im1Area, im2Area, imStatsCtrl, mu1, mu2): 

693 """Calculate the mean of each of two exposures and the variance 

694 and covariance of their difference. The variance is calculated 

695 via afwMath, and the covariance via the methods in Astier+19 

696 (appendix A). In theory, var = covariance[0,0]. This should 

697 be validated, and in the future, we may decide to just keep 

698 one (covariance). 

699 

700 Parameters 

701 ---------- 

702 im1Area : `lsst.afw.image.maskedImage.MaskedImageF` 

703 Masked image from exposure 1. 

704 im2Area : `lsst.afw.image.maskedImage.MaskedImageF` 

705 Masked image from exposure 2. 

706 imStatsCtrl : `lsst.afw.math.StatisticsControl` 

707 Statistics control object. 

708 mu1: `float` 

709 Clipped mean of im1Area (ADU). 

710 mu2: `float` 

711 Clipped mean of im2Area (ADU). 

712 

713 Returns 

714 ------- 

715 mu : `float` or `NaN` 

716 0.5*(mu1 + mu2), where mu1, and mu2 are the clipped means 

717 of the regions in both exposures. If either mu1 or m2 are 

718 NaN's, the returned value is NaN. 

719 varDiff : `float` or `NaN` 

720 Half of the clipped variance of the difference of the 

721 regions inthe two input exposures. If either mu1 or m2 are 

722 NaN's, the returned value is NaN. 

723 covDiffAstier : `list` or `NaN` 

724 List with tuples of the form (dx, dy, var, cov, npix), where: 

725 dx : `int` 

726 Lag in x 

727 dy : `int` 

728 Lag in y 

729 var : `float` 

730 Variance at (dx, dy). 

731 cov : `float` 

732 Covariance at (dx, dy). 

733 nPix : `int` 

734 Number of pixel pairs used to evaluate var and cov. 

735 

736 If either mu1 or m2 are NaN's, the returned value is NaN. 

737 """ 

738 if np.isnan(mu1) or np.isnan(mu2): 

739 self.log.warning("Mean of amp in image 1 or 2 is NaN: %f, %f.", mu1, mu2) 

740 return np.nan, np.nan, None 

741 mu = 0.5*(mu1 + mu2) 

742 

743 # Take difference of pairs 

744 # symmetric formula: diff = (mu2*im1-mu1*im2)/(0.5*(mu1+mu2)) 

745 temp = im2Area.clone() 

746 temp *= mu1 

747 diffIm = im1Area.clone() 

748 diffIm *= mu2 

749 diffIm -= temp 

750 diffIm /= mu 

751 

752 if self.config.binSize > 1: 

753 diffIm = afwMath.binImage(diffIm, self.config.binSize) 

754 

755 # Variance calculation via afwMath 

756 varDiff = 0.5*(afwMath.makeStatistics(diffIm, afwMath.VARIANCECLIP, imStatsCtrl).getValue()) 

757 

758 # Covariances calculations 

759 # Get the pixels that were not clipped 

760 varClip = afwMath.makeStatistics(diffIm, afwMath.VARIANCECLIP, imStatsCtrl).getValue() 

761 meanClip = afwMath.makeStatistics(diffIm, afwMath.MEANCLIP, imStatsCtrl).getValue() 

762 cut = meanClip + self.config.nSigmaClipPtc*np.sqrt(varClip) 

763 unmasked = np.where(np.fabs(diffIm.image.array) <= cut, 1, 0) 

764 

765 # Get the pixels in the mask planes of the difference image 

766 # that were ignored by the clipping algorithm 

767 wDiff = np.where(diffIm.getMask().getArray() == 0, 1, 0) 

768 # Combine the two sets of pixels ('1': use; '0': don't use) 

769 # into a final weight matrix to be used in the covariance 

770 # calculations below. 

771 w = unmasked*wDiff 

772 

773 if np.sum(w) < self.config.minNumberGoodPixelsForCovariance/(self.config.binSize**2): 

774 self.log.warning("Number of good points for covariance calculation (%s) is less " 

775 "(than threshold %s)", np.sum(w), 

776 self.config.minNumberGoodPixelsForCovariance/(self.config.binSize**2)) 

777 return np.nan, np.nan, None 

778 

779 maxRangeCov = self.config.maximumRangeCovariancesAstier 

780 

781 # Calculate covariances via FFT. 

782 shapeDiff = np.array(diffIm.image.array.shape) 

783 # Calculate the sizes of FFT dimensions. 

784 s = shapeDiff + maxRangeCov 

785 tempSize = np.array(np.log(s)/np.log(2.)).astype(int) 

786 fftSize = np.array(2**(tempSize+1)).astype(int) 

787 fftShape = (fftSize[0], fftSize[1]) 

788 c = CovFastFourierTransform(diffIm.image.array, w, fftShape, maxRangeCov) 

789 # np.sum(w) is the same as npix[0][0] returned in covDiffAstier 

790 try: 

791 covDiffAstier = c.reportCovFastFourierTransform(maxRangeCov) 

792 except ValueError: 

793 # This is raised if there are not enough pixels. 

794 self.log.warning("Not enough pixels covering the requested covariance range in x/y (%d)", 

795 self.config.maximumRangeCovariancesAstier) 

796 return np.nan, np.nan, None 

797 

798 # Compare Cov[0,0] and afwMath.VARIANCECLIP covDiffAstier[0] 

799 # is the Cov[0,0] element, [3] is the variance, and there's a 

800 # factor of 0.5 difference with afwMath.VARIANCECLIP. 

801 thresholdPercentage = self.config.thresholdDiffAfwVarVsCov00 

802 fractionalDiff = 100*np.fabs(1 - varDiff/(covDiffAstier[0][3]*0.5)) 

803 if fractionalDiff >= thresholdPercentage: 

804 self.log.warning("Absolute fractional difference between afwMatch.VARIANCECLIP and Cov[0,0] " 

805 "is more than %f%%: %f", thresholdPercentage, fractionalDiff) 

806 

807 return mu, varDiff, covDiffAstier 

808 

809 def getImageAreasMasksStats(self, exposure1, exposure2, region=None): 

810 """Get image areas in a region as well as masks and statistic objects. 

811 

812 Parameters 

813 ---------- 

814 exposure1 : `lsst.afw.image.ExposureF` 

815 First exposure of flat field pair. 

816 exposure2 : `lsst.afw.image.ExposureF` 

817 Second exposure of flat field pair. 

818 region : `lsst.geom.Box2I`, optional 

819 Region of each exposure where to perform the calculations 

820 (e.g, an amplifier). 

821 

822 Returns 

823 ------- 

824 im1Area : `lsst.afw.image.MaskedImageF` 

825 Masked image from exposure 1. 

826 im2Area : `lsst.afw.image.MaskedImageF` 

827 Masked image from exposure 2. 

828 imStatsCtrl : `lsst.afw.math.StatisticsControl` 

829 Statistics control object. 

830 mu1 : `float` 

831 Clipped mean of im1Area (ADU). 

832 mu2 : `float` 

833 Clipped mean of im2Area (ADU). 

834 """ 

835 if region is not None: 

836 im1Area = exposure1.maskedImage[region] 

837 im2Area = exposure2.maskedImage[region] 

838 else: 

839 im1Area = exposure1.maskedImage 

840 im2Area = exposure2.maskedImage 

841 

842 # Get mask planes and construct statistics control object from one 

843 # of the exposures 

844 imMaskVal = exposure1.getMask().getPlaneBitMask(self.config.maskNameList) 

845 imStatsCtrl = afwMath.StatisticsControl(self.config.nSigmaClipPtc, 

846 self.config.nIterSigmaClipPtc, 

847 imMaskVal) 

848 imStatsCtrl.setNanSafe(True) 

849 imStatsCtrl.setAndMask(imMaskVal) 

850 

851 mu1 = afwMath.makeStatistics(im1Area, afwMath.MEANCLIP, imStatsCtrl).getValue() 

852 mu2 = afwMath.makeStatistics(im2Area, afwMath.MEANCLIP, imStatsCtrl).getValue() 

853 

854 return (im1Area, im2Area, imStatsCtrl, mu1, mu2) 

855 

856 def getGainFromFlatPair(self, im1Area, im2Area, imStatsCtrl, mu1, mu2, 

857 correctionType='NONE', readNoise=None): 

858 """Estimate the gain from a single pair of flats. 

859 

860 The basic premise is 1/g = <(I1 - I2)^2/(I1 + I2)> = 1/const, 

861 where I1 and I2 correspond to flats 1 and 2, respectively. 

862 Corrections for the variable QE and the read-noise are then 

863 made following the derivation in Robert Lupton's forthcoming 

864 book, which gets 

865 

866 1/g = <(I1 - I2)^2/(I1 + I2)> - 1/mu(sigma^2 - 1/2g^2). 

867 

868 This is a quadratic equation, whose solutions are given by: 

869 

870 g = mu +/- sqrt(2*sigma^2 - 2*const*mu + mu^2)/(2*const*mu*2 

871 - 2*sigma^2) 

872 

873 where 'mu' is the average signal level and 'sigma' is the 

874 amplifier's readnoise. The positive solution will be used. 

875 The way the correction is applied depends on the value 

876 supplied for correctionType. 

877 

878 correctionType is one of ['NONE', 'SIMPLE' or 'FULL'] 

879 'NONE' : uses the 1/g = <(I1 - I2)^2/(I1 + I2)> formula. 

880 'SIMPLE' : uses the gain from the 'NONE' method for the 

881 1/2g^2 term. 

882 'FULL' : solves the full equation for g, discarding the 

883 non-physical solution to the resulting quadratic. 

884 

885 Parameters 

886 ---------- 

887 im1Area : `lsst.afw.image.maskedImage.MaskedImageF` 

888 Masked image from exposure 1. 

889 im2Area : `lsst.afw.image.maskedImage.MaskedImageF` 

890 Masked image from exposure 2. 

891 imStatsCtrl : `lsst.afw.math.StatisticsControl` 

892 Statistics control object. 

893 mu1: `float` 

894 Clipped mean of im1Area (ADU). 

895 mu2: `float` 

896 Clipped mean of im2Area (ADU). 

897 correctionType : `str`, optional 

898 The correction applied, one of ['NONE', 'SIMPLE', 'FULL'] 

899 readNoise : `float`, optional 

900 Amplifier readout noise (ADU). 

901 

902 Returns 

903 ------- 

904 gain : `float` 

905 Gain, in e/ADU. 

906 

907 Raises 

908 ------ 

909 RuntimeError 

910 Raise if `correctionType` is not one of 'NONE', 

911 'SIMPLE', or 'FULL'. 

912 """ 

913 if correctionType not in ['NONE', 'SIMPLE', 'FULL']: 

914 raise RuntimeError("Unknown correction type: %s" % correctionType) 

915 

916 if correctionType != 'NONE' and not np.isfinite(readNoise): 

917 self.log.warning("'correctionType' in 'getGainFromFlatPair' is %s, " 

918 "but 'readNoise' is NaN. Setting 'correctionType' " 

919 "to 'NONE', so a gain value will be estimated without " 

920 "corrections." % correctionType) 

921 correctionType = 'NONE' 

922 

923 mu = 0.5*(mu1 + mu2) 

924 

925 # ratioIm = (I1 - I2)^2 / (I1 + I2) 

926 temp = im2Area.clone() 

927 ratioIm = im1Area.clone() 

928 ratioIm -= temp 

929 ratioIm *= ratioIm 

930 

931 # Sum of pairs 

932 sumIm = im1Area.clone() 

933 sumIm += temp 

934 

935 ratioIm /= sumIm 

936 

937 const = afwMath.makeStatistics(ratioIm, afwMath.MEAN, imStatsCtrl).getValue() 

938 gain = 1. / const 

939 

940 if correctionType == 'SIMPLE': 

941 gain = 1/(const - (1/mu)*(readNoise**2 - (1/2*gain**2))) 

942 elif correctionType == 'FULL': 

943 root = np.sqrt(mu**2 - 2*mu*const + 2*readNoise**2) 

944 denom = (2*const*mu - 2*readNoise**2) 

945 positiveSolution = (root + mu)/denom 

946 gain = positiveSolution 

947 

948 return gain 

949 

950 def getReadNoise(self, exposureMetadata, taskMetadata, ampName): 

951 """Gets readout noise for an amp from ISR metadata. 

952 

953 If possible, this attempts to get the now-standard headers 

954 added to the exposure itself. If not found there, the ISR 

955 TaskMetadata is searched. If neither of these has the value, 

956 warn and set the read noise to NaN. 

957 

958 Parameters 

959 ---------- 

960 exposureMetadata : `lsst.daf.base.PropertySet` 

961 Metadata to check for read noise first. 

962 taskMetadata : `lsst.pipe.base.TaskMetadata` 

963 List of exposures metadata from ISR for this exposure. 

964 ampName : `str` 

965 Amplifier name. 

966 

967 Returns 

968 ------- 

969 readNoise : `float` 

970 The read noise for this set of exposure/amplifier. 

971 """ 

972 # Try from the exposure first. 

973 expectedKey = f"LSST ISR OVERSCAN RESIDUAL SERIAL STDEV {ampName}" 

974 if expectedKey in exposureMetadata: 

975 return exposureMetadata[expectedKey] 

976 

977 # If not, try getting it from the task metadata. 

978 expectedKey = f"RESIDUAL STDEV {ampName}" 

979 if "isr" in taskMetadata: 

980 if expectedKey in taskMetadata["isr"]: 

981 return taskMetadata["isr"][expectedKey] 

982 

983 self.log.warning("Median readout noise from ISR metadata for amp %s " 

984 "could not be calculated." % ampName) 

985 return np.nan 

986 

987 def computeGaussianHistogramParameters(self, im1Area, im2Area, imStatsCtrl, mu1, mu2): 

988 """Compute KS test for a Gaussian model fit to a histogram of the 

989 difference image. 

990 

991 Parameters 

992 ---------- 

993 im1Area : `lsst.afw.image.MaskedImageF` 

994 Masked image from exposure 1. 

995 im2Area : `lsst.afw.image.MaskedImageF` 

996 Masked image from exposure 2. 

997 imStatsCtrl : `lsst.afw.math.StatisticsControl` 

998 Statistics control object. 

999 mu1 : `float` 

1000 Clipped mean of im1Area (ADU). 

1001 mu2 : `float` 

1002 Clipped mean of im2Area (ADU). 

1003 

1004 Returns 

1005 ------- 

1006 varFit : `float` 

1007 Variance from the Gaussian fit. 

1008 chi2Dof : `float` 

1009 Chi-squared per degree of freedom of Gaussian fit. 

1010 kspValue : `float` 

1011 The KS test p-value for the Gaussian fit. 

1012 

1013 Notes 

1014 ----- 

1015 The algorithm here was originally developed by Aaron Roodman. 

1016 Tests on the full focal plane of LSSTCam during testing has shown 

1017 that a KS test p-value cut of 0.01 is a good discriminant for 

1018 well-behaved flat pairs (p>0.01) and poorly behaved non-Gaussian 

1019 flat pairs (p<0.01). 

1020 """ 

1021 diffExp = im1Area.clone() 

1022 diffExp -= im2Area 

1023 

1024 sel = (((diffExp.mask.array & imStatsCtrl.getAndMask()) == 0) 

1025 & np.isfinite(diffExp.mask.array)) 

1026 diffArr = diffExp.image.array[sel] 

1027 

1028 numOk = len(diffArr) 

1029 

1030 if numOk >= self.config.ksHistMinDataValues and np.isfinite(mu1) and np.isfinite(mu2): 

1031 # Create a histogram symmetric around zero, with a bin size 

1032 # determined from the expected variance given by the average of 

1033 # the input signal levels. 

1034 lim = self.config.ksHistLimitMultiplier * np.sqrt((mu1 + mu2)/2.) 

1035 yVals, binEdges = np.histogram(diffArr, bins=self.config.ksHistNBins, range=[-lim, lim]) 

1036 

1037 # Fit the histogram with a Gaussian model. 

1038 model = GaussianModel() 

1039 yVals = yVals.astype(np.float64) 

1040 xVals = ((binEdges[0: -1] + binEdges[1:])/2.).astype(np.float64) 

1041 errVals = np.sqrt(yVals) 

1042 errVals[(errVals == 0.0)] = 1.0 

1043 pars = model.guess(yVals, x=xVals) 

1044 with warnings.catch_warnings(): 

1045 warnings.simplefilter("ignore") 

1046 # The least-squares fitter sometimes spouts (spurious) warnings 

1047 # when the model is very bad. Swallow these warnings now and 

1048 # let the KS test check the model below. 

1049 out = model.fit( 

1050 yVals, 

1051 pars, 

1052 x=xVals, 

1053 weights=1./errVals, 

1054 calc_covar=True, 

1055 method="least_squares", 

1056 ) 

1057 

1058 # Calculate chi2. 

1059 chiArr = out.residual 

1060 nDof = len(yVals) - 3 

1061 chi2Dof = np.sum(chiArr**2.)/nDof 

1062 sigmaFit = out.params["sigma"].value 

1063 

1064 # Calculate KS test p-value for the fit. 

1065 ksResult = scipy.stats.ks_1samp( 

1066 diffArr, 

1067 scipy.stats.norm.cdf, 

1068 (out.params["center"].value, sigmaFit), 

1069 ) 

1070 

1071 kspValue = ksResult.pvalue 

1072 if kspValue < 1e-15: 

1073 kspValue = 0.0 

1074 

1075 varFit = sigmaFit**2. 

1076 

1077 else: 

1078 varFit = np.nan 

1079 chi2Dof = np.nan 

1080 kspValue = 0.0 

1081 

1082 return varFit, chi2Dof, kspValue