30 import lsst.pipe.base.connectionTypes
as cT
32 from .astierCovPtcUtils
import (CovFastFourierTransform, computeCovDirect)
33 from .astierCovPtcFit
import makeCovArray
38 __all__ = [
'PhotonTransferCurveExtractConfig',
'PhotonTransferCurveExtractTask']
42 dimensions=(
"instrument",
"detector")):
45 name=
"ptcInputExposurePairs",
46 doc=
"Input post-ISR processed exposure pairs (flats) to"
47 "measure covariances from.",
48 storageClass=
"Exposure",
49 dimensions=(
"instrument",
"exposure",
"detector"),
54 outputCovariances = cT.Output(
55 name=
"ptcCovariances",
56 doc=
"Extracted flat (co)variances.",
57 storageClass=
"PhotonTransferCurveDataset",
58 dimensions=(
"instrument",
"exposure",
"detector"),
64 pipelineConnections=PhotonTransferCurveExtractConnections):
65 """Configuration for the measurement of covariances from flats.
67 matchByExposureId = pexConfig.Field(
69 doc=
"Should exposures be matched by ID rather than exposure time?",
72 maximumRangeCovariancesAstier = pexConfig.Field(
74 doc=
"Maximum range of covariances as in Astier+19",
77 covAstierRealSpace = pexConfig.Field(
79 doc=
"Calculate covariances in real space or via FFT? (see appendix A of Astier+19).",
82 binSize = pexConfig.Field(
84 doc=
"Bin the image by this factor in both dimensions.",
87 minMeanSignal = pexConfig.DictField(
90 doc=
"Minimum values (inclusive) of mean signal (in ADU) above which to consider, per amp."
91 " The same cut is applied to all amps if this dictionary is of the form"
92 " {'ALL_AMPS': value}",
93 default={
'ALL_AMPS': 0.0},
95 maxMeanSignal = pexConfig.DictField(
98 doc=
"Maximum values (inclusive) of mean signal (in ADU) below which to consider, per amp."
99 " The same cut is applied to all amps if this dictionary is of the form"
100 " {'ALL_AMPS': value}",
101 default={
'ALL_AMPS': 1e6},
103 maskNameList = pexConfig.ListField(
105 doc=
"Mask list to exclude from statistics calculations.",
106 default=[
'SUSPECT',
'BAD',
'NO_DATA',
'SAT'],
108 nSigmaClipPtc = pexConfig.Field(
110 doc=
"Sigma cut for afwMath.StatisticsControl()",
113 nIterSigmaClipPtc = pexConfig.Field(
115 doc=
"Number of sigma-clipping iterations for afwMath.StatisticsControl()",
118 minNumberGoodPixelsForCovariance = pexConfig.Field(
120 doc=
"Minimum number of acceptable good pixels per amp to calculate the covariances (via FFT or"
124 thresholdDiffAfwVarVsCov00 = pexConfig.Field(
126 doc=
"If the absolute fractional differece between afwMath.VARIANCECLIP and Cov00 "
127 "for a region of a difference image is greater than this threshold (percentage), "
128 "a warning will be issued.",
131 detectorMeasurementRegion = pexConfig.ChoiceField(
133 doc=
"Region of each exposure where to perform the calculations (amplifier or full image).",
136 "AMP":
"Amplifier of the detector.",
137 "FULL":
"Full image."
140 numEdgeSuspect = pexConfig.Field(
142 doc=
"Number of edge pixels to be flagged as untrustworthy.",
145 edgeMaskLevel = pexConfig.ChoiceField(
147 doc=
"Mask edge pixels in which coordinate frame: DETECTOR or AMP?",
150 'DETECTOR':
'Mask only the edges of the full detector.',
151 'AMP':
'Mask edges of each amplifier.',
157 pipeBase.CmdLineTask):
158 """Task to measure covariances from flat fields.
159 This task receives as input a list of flat-field images
160 (flats), and sorts these flats in pairs taken at the
161 same time (if there's a different number of flats,
162 those flats are discarded). The mean, variance, and
163 covariances are measured from the difference of the flat
164 pairs at a given time. The variance is calculated
165 via afwMath, and the covariance via the methods in Astier+19
166 (appendix A). In theory, var = covariance[0,0]. This should
167 be validated, and in the future, we may decide to just keep
170 The measured covariances at a particular time (along with
171 other quantities such as the mean) are stored in a PTC dataset
172 object (`PhotonTransferCurveDataset`), which gets partially
173 filled. The number of partially-filled PTC dataset objects
174 will be less than the number of input exposures, but gen3
175 requires/assumes that the number of input dimensions matches
176 bijectively the number of output dimensions. Therefore, a
177 number of "dummy" PTC dataset are inserted in the output list
178 that has the partially-filled PTC datasets with the covariances.
179 This output list will be used as input of
180 `PhotonTransferCurveSolveTask`, which will assemble the multiple
181 `PhotonTransferCurveDataset`s into a single one in order to fit
182 the measured covariances as a function of flux to a particular
185 Astier+19: "The Shape of the Photon Transfer Curve of CCD
186 sensors", arXiv:1905.08677.
188 ConfigClass = PhotonTransferCurveExtractConfig
189 _DefaultName =
'cpPtcExtract'
192 """Ensure that the input and output dimensions are passed along.
196 butlerQC : `~lsst.daf.butler.butlerQuantumContext.ButlerQuantumContext`
197 Butler to operate on.
198 inputRefs : `~lsst.pipe.base.connections.InputQuantizedConnection`
199 Input data refs to load.
200 ouptutRefs : `~lsst.pipe.base.connections.OutputQuantizedConnection`
201 Output data refs to persist.
203 inputs = butlerQC.get(inputRefs)
205 inputs[
'inputDims'] = [expId.dataId[
'exposure']
for expId
in inputRefs.inputExp]
208 if self.config.matchByExposureId:
213 outputs = self.
runrun(**inputs)
214 butlerQC.put(outputs, outputRefs)
216 def run(self, inputExp, inputDims):
217 """Measure covariances from difference of flat pairs
221 inputExp : `dict` [`float`,
222 (`~lsst.afw.image.exposure.exposure.ExposureF`,
223 `~lsst.afw.image.exposure.exposure.ExposureF`, ...,
224 `~lsst.afw.image.exposure.exposure.ExposureF`)]
225 Dictionary that groups flat-field exposures that have the same
226 exposure time (seconds).
229 List of exposure IDs.
233 detector =
list(inputExp.values())[0][0][0].getDetector()
234 detNum = detector.getId()
235 amps = detector.getAmplifiers()
236 ampNames = [amp.getName()
for amp
in amps]
239 maxMeanSignalDict = {ampName: 1e6
for ampName
in ampNames}
240 minMeanSignalDict = {ampName: 0.0
for ampName
in ampNames}
241 for ampName
in ampNames:
242 if 'ALL_AMPS' in self.config.maxMeanSignal:
243 maxMeanSignalDict[ampName] = self.config.maxMeanSignal[
'ALL_AMPS']
244 elif ampName
in self.config.maxMeanSignal:
245 maxMeanSignalDict[ampName] = self.config.maxMeanSignal[ampName]
247 if 'ALL_AMPS' in self.config.minMeanSignal:
248 minMeanSignalDict[ampName] = self.config.minMeanSignal[
'ALL_AMPS']
249 elif ampName
in self.config.minMeanSignal:
250 minMeanSignalDict[ampName] = self.config.minMeanSignal[ampName]
252 tags = [(
'mu',
'<f8'), (
'afwVar',
'<f8'), (
'i',
'<i8'), (
'j',
'<i8'), (
'var',
'<f8'),
253 (
'cov',
'<f8'), (
'npix',
'<i8'), (
'ext',
'<i8'), (
'expTime',
'<f8'), (
'ampName',
'<U3')]
256 self.config.maximumRangeCovariancesAstier)
258 for ampName
in ampNames:
259 dummyPtcDataset.setAmpValues(ampName)
261 partialPtcDatasetList = []
264 for i
in range(len(inputDims)):
265 partialPtcDatasetList.append(dummyPtcDataset)
267 if self.config.numEdgeSuspect > 0:
269 self.log.
info(f
"Masking {self.config.numEdgeSuspect} pixels from the edges "
270 "of all exposures as SUSPECT.")
272 for expTime
in inputExp:
273 exposures = inputExp[expTime]
274 if len(exposures) == 1:
275 self.log.
warn(f
"Only one exposure found at expTime {expTime}. Dropping exposure "
276 f
"{exposures[0][1]}")
280 exp1, expId1 = exposures[0]
281 exp2, expId2 = exposures[1]
282 if len(exposures) > 2:
283 self.log.
warn(f
"Already found 2 exposures at expTime {expTime}. "
284 "Ignoring exposures: "
285 f
"{i[1] for i in exposures[2:]}")
287 if self.config.numEdgeSuspect > 0:
288 isrTask.maskEdges(exp1, numEdgePixels=self.config.numEdgeSuspect,
289 maskPlane=
"SUSPECT", level=self.config.edgeMaskLevel)
290 isrTask.maskEdges(exp2, numEdgePixels=self.config.numEdgeSuspect,
291 maskPlane=
"SUSPECT", level=self.config.edgeMaskLevel)
295 self.config.maximumRangeCovariancesAstier)
296 for ampNumber, amp
in enumerate(detector):
297 ampName = amp.getName()
299 doRealSpace = self.config.covAstierRealSpace
300 if self.config.detectorMeasurementRegion ==
'AMP':
301 region = amp.getBBox()
302 elif self.config.detectorMeasurementRegion ==
'FULL':
308 muDiff, varDiff, covAstier = self.
measureMeanVarCovmeasureMeanVarCov(exp1, exp2, region=region,
309 covAstierRealSpace=doRealSpace)
316 if np.isnan(muDiff)
or np.isnan(varDiff)
or (covAstier
is None):
317 msg = (f
"NaN mean or var, or None cov in amp {ampName} in exposure pair {expId1},"
318 f
" {expId2} of detector {detNum}.")
322 covArray = np.full((1, self.config.maximumRangeCovariancesAstier,
323 self.config.maximumRangeCovariancesAstier), np.nan)
324 covSqrtWeights = np.full_like(covArray, np.nan)
326 if (muDiff <= minMeanSignalDict[ampName])
or (muDiff >= maxMeanSignalDict[ampName]):
329 if covAstier
is not None:
330 tupleRows = [(muDiff, varDiff) + covRow + (ampNumber, expTime,
331 ampName)
for covRow
in covAstier]
332 tempStructArray = np.array(tupleRows, dtype=tags)
334 self.config.maximumRangeCovariancesAstier)
335 covSqrtWeights = np.nan_to_num(1./np.sqrt(vcov))
339 covArray *= varFactor**2
341 covArray[0, 0] /= varFactor
343 partialPtcDataset.setAmpValues(ampName, rawExpTime=[expTime], rawMean=[muDiff],
344 rawVar=[varDiff], inputExpIdPair=[(expId1, expId2)],
345 expIdMask=[expIdMask], covArray=covArray,
346 covSqrtWeights=covSqrtWeights)
351 datasetIndex = np.where(expId1 == np.array(inputDims))[0][0]
352 partialPtcDatasetList[datasetIndex] = partialPtcDataset
354 if nAmpsNan == len(ampNames):
355 msg = f
"NaN mean in all amps of exposure pair {expId1}, {expId2} of detector {detNum}."
357 return pipeBase.Struct(
358 outputCovariances=partialPtcDatasetList,
362 """Calculate the mean of each of two exposures and the variance
363 and covariance of their difference. The variance is calculated
364 via afwMath, and the covariance via the methods in Astier+19
365 (appendix A). In theory, var = covariance[0,0]. This should
366 be validated, and in the future, we may decide to just keep
371 exposure1 : `lsst.afw.image.exposure.exposure.ExposureF`
372 First exposure of flat field pair.
373 exposure2 : `lsst.afw.image.exposure.exposure.ExposureF`
374 Second exposure of flat field pair.
375 region : `lsst.geom.Box2I`, optional
376 Region of each exposure where to perform the calculations (e.g, an amplifier).
377 covAstierRealSpace : `bool`, optional
378 Should the covariannces in Astier+19 be calculated in real space or via FFT?
379 See Appendix A of Astier+19.
383 mu : `float` or `NaN`
384 0.5*(mu1 + mu2), where mu1, and mu2 are the clipped means of the regions in
385 both exposures. If either mu1 or m2 are NaN's, the returned value is NaN.
386 varDiff : `float` or `NaN`
387 Half of the clipped variance of the difference of the regions inthe two input
388 exposures. If either mu1 or m2 are NaN's, the returned value is NaN.
389 covDiffAstier : `list` or `NaN`
390 List with tuples of the form (dx, dy, var, cov, npix), where:
396 Variance at (dx, dy).
398 Covariance at (dx, dy).
400 Number of pixel pairs used to evaluate var and cov.
401 If either mu1 or m2 are NaN's, the returned value is NaN.
404 if region
is not None:
405 im1Area = exposure1.maskedImage[region]
406 im2Area = exposure2.maskedImage[region]
408 im1Area = exposure1.maskedImage
409 im2Area = exposure2.maskedImage
411 if self.config.binSize > 1:
415 im1MaskVal = exposure1.getMask().getPlaneBitMask(self.config.maskNameList)
417 self.config.nIterSigmaClipPtc,
419 im1StatsCtrl.setNanSafe(
True)
420 im1StatsCtrl.setAndMask(im1MaskVal)
422 im2MaskVal = exposure2.getMask().getPlaneBitMask(self.config.maskNameList)
424 self.config.nIterSigmaClipPtc,
426 im2StatsCtrl.setNanSafe(
True)
427 im2StatsCtrl.setAndMask(im2MaskVal)
432 if np.isnan(mu1)
or np.isnan(mu2):
433 self.log.
warn(f
"Mean of amp in image 1 or 2 is NaN: {mu1}, {mu2}.")
434 return np.nan, np.nan,
None
439 temp = im2Area.clone()
441 diffIm = im1Area.clone()
446 diffImMaskVal = diffIm.getMask().getPlaneBitMask(self.config.maskNameList)
448 self.config.nIterSigmaClipPtc,
450 diffImStatsCtrl.setNanSafe(
True)
451 diffImStatsCtrl.setAndMask(diffImMaskVal)
460 cut = meanClip + self.config.nSigmaClipPtc*np.sqrt(varClip)
461 unmasked = np.where(np.fabs(diffIm.image.array) <= cut, 1, 0)
465 wDiff = np.where(diffIm.getMask().getArray() == 0, 1, 0)
470 if np.sum(w) < self.config.minNumberGoodPixelsForCovariance:
471 self.log.
warn(f
"Number of good points for covariance calculation ({np.sum(w)}) is less "
472 f
"(than threshold {self.config.minNumberGoodPixelsForCovariance})")
473 return np.nan, np.nan,
None
475 maxRangeCov = self.config.maximumRangeCovariancesAstier
476 if covAstierRealSpace:
481 shapeDiff = np.array(diffIm.image.array.shape)
483 s = shapeDiff + maxRangeCov
484 tempSize = np.array(np.log(s)/np.log(2.)).astype(int)
485 fftSize = np.array(2**(tempSize+1)).astype(int)
486 fftShape = (fftSize[0], fftSize[1])
489 covDiffAstier = c.reportCovFastFourierTransform(maxRangeCov)
494 thresholdPercentage = self.config.thresholdDiffAfwVarVsCov00
495 fractionalDiff = 100*np.fabs(1 - varDiff/(covDiffAstier[0][3]*0.5))
496 if fractionalDiff >= thresholdPercentage:
497 self.log.
warn(
"Absolute fractional difference between afwMatch.VARIANCECLIP and Cov[0,0] "
498 f
"is more than {thresholdPercentage}%: {fractionalDiff}")
500 return mu, varDiff, covDiffAstier
Pass parameters to a Statistics object.
daf::base::PropertyList * list
Statistics makeStatistics(lsst::afw::image::Image< Pixel > const &img, lsst::afw::image::Mask< image::MaskPixel > const &msk, int const flags, StatisticsControl const &sctrl=StatisticsControl())
Handle a watered-down front-end to the constructor (no variance)
std::shared_ptr< ImageT > binImage(ImageT const &inImage, int const binX, int const binY, lsst::afw::math::Property const flags=lsst::afw::math::MEAN)
def makeCovArray(inputTuple, maxRangeFromTuple=8)
def computeCovDirect(diffImage, weightImage, maxRange)
def sigmaClipCorrection(nSigClip)
def arrangeFlatsByExpTime(exposureList, exposureIdList)
def arrangeFlatsByExpId(exposureList, exposureIdList)