Coverage for python/lsst/cp/pipe/ptc/cpExtractPtcTask.py: 11%
320 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-04 22:49 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-04 22:49 +0000
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
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)
34import lsst.pipe.base.connectionTypes as cT
36from lsst.ip.isr import PhotonTransferCurveDataset
37from lsst.ip.isr import IsrTask
39__all__ = ['PhotonTransferCurveExtractConfig', 'PhotonTransferCurveExtractTask']
42class PhotonTransferCurveExtractConnections(pipeBase.PipelineTaskConnections,
43 dimensions=("instrument", "detector")):
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 )
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 )
207class PhotonTransferCurveExtractTask(pipeBase.PipelineTask):
208 """Task to measure covariances from flat fields.
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).
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.
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`).
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).
249 Reference: Astier+19: "The Shape of the Photon Transfer Curve of CCD
250 sensors", arXiv:1905.08677.
251 """
253 ConfigClass = PhotonTransferCurveExtractConfig
254 _DefaultName = 'cpPtcExtract'
256 def runQuantum(self, butlerQC, inputRefs, outputRefs):
257 """Ensure that the input and output dimensions are passed along.
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]
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'])
284 outputs = self.run(**inputs)
285 outputs = self._guaranteeOutputs(inputs['inputDims'], outputs, outputRefs)
286 butlerQC.put(outputs, outputRefs)
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.
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:
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.
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)
322 def run(self, inputExp, inputDims, taskMetadata):
324 """Measure covariances from difference of flat pairs
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.
338 Returns
339 -------
340 results : `lsst.pipe.base.Struct`
341 The resulting Struct contains:
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]
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]
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]
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))
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])
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)
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)
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()
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)
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]
457 auxDict[key] = value
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
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)
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
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
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
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)
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
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)
542 covArray, vcov, _ = self.makeCovArray(tempStructArray,
543 self.config.maximumRangeCovariancesAstier)
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.")
550 covSqrtWeights = np.nan_to_num(1./np.sqrt(vcov))
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
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
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 )
592 partialPtcDataset.setAuxValuesPartialDataset(auxDict)
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
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)
618 return pipeBase.Struct(
619 outputCovariances=partialPtcDatasetList,
620 )
622 def makeCovArray(self, inputTuple, maxRangeFromTuple):
623 """Make covariances array from tuple.
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.
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)
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
690 return cov, var, muVals
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).
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).
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.
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)
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
752 if self.config.binSize > 1:
753 diffIm = afwMath.binImage(diffIm, self.config.binSize)
755 # Variance calculation via afwMath
756 varDiff = 0.5*(afwMath.makeStatistics(diffIm, afwMath.VARIANCECLIP, imStatsCtrl).getValue())
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)
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
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
779 maxRangeCov = self.config.maximumRangeCovariancesAstier
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
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)
807 return mu, varDiff, covDiffAstier
809 def getImageAreasMasksStats(self, exposure1, exposure2, region=None):
810 """Get image areas in a region as well as masks and statistic objects.
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).
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
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)
851 mu1 = afwMath.makeStatistics(im1Area, afwMath.MEANCLIP, imStatsCtrl).getValue()
852 mu2 = afwMath.makeStatistics(im2Area, afwMath.MEANCLIP, imStatsCtrl).getValue()
854 return (im1Area, im2Area, imStatsCtrl, mu1, mu2)
856 def getGainFromFlatPair(self, im1Area, im2Area, imStatsCtrl, mu1, mu2,
857 correctionType='NONE', readNoise=None):
858 """Estimate the gain from a single pair of flats.
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
866 1/g = <(I1 - I2)^2/(I1 + I2)> - 1/mu(sigma^2 - 1/2g^2).
868 This is a quadratic equation, whose solutions are given by:
870 g = mu +/- sqrt(2*sigma^2 - 2*const*mu + mu^2)/(2*const*mu*2
871 - 2*sigma^2)
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.
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.
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).
902 Returns
903 -------
904 gain : `float`
905 Gain, in e/ADU.
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)
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'
923 mu = 0.5*(mu1 + mu2)
925 # ratioIm = (I1 - I2)^2 / (I1 + I2)
926 temp = im2Area.clone()
927 ratioIm = im1Area.clone()
928 ratioIm -= temp
929 ratioIm *= ratioIm
931 # Sum of pairs
932 sumIm = im1Area.clone()
933 sumIm += temp
935 ratioIm /= sumIm
937 const = afwMath.makeStatistics(ratioIm, afwMath.MEAN, imStatsCtrl).getValue()
938 gain = 1. / const
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
948 return gain
950 def getReadNoise(self, exposureMetadata, taskMetadata, ampName):
951 """Gets readout noise for an amp from ISR metadata.
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.
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.
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]
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]
983 self.log.warning("Median readout noise from ISR metadata for amp %s "
984 "could not be calculated." % ampName)
985 return np.nan
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.
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).
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.
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
1024 sel = (((diffExp.mask.array & imStatsCtrl.getAndMask()) == 0)
1025 & np.isfinite(diffExp.mask.array))
1026 diffArr = diffExp.image.array[sel]
1028 numOk = len(diffArr)
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])
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 )
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
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 )
1071 kspValue = ksResult.pvalue
1072 if kspValue < 1e-15:
1073 kspValue = 0.0
1075 varFit = sigmaFit**2.
1077 else:
1078 varFit = np.nan
1079 chi2Dof = np.nan
1080 kspValue = 0.0
1082 return varFit, chi2Dof, kspValue