23 import matplotlib.pyplot
as plt
24 from collections
import Counter
29 from .utils
import (fitLeastSq, fitBootstrap, funcPolynomial, funcAstier)
30 from scipy.optimize
import least_squares
34 from .astierCovPtcUtils
import (fftSize, CovFft, computeCovDirect, fitData)
35 from .linearity
import LinearitySolveTask
36 from .photodiode
import getBOTphotodiodeData
41 __all__ = [
'MeasurePhotonTransferCurveTask',
42 'MeasurePhotonTransferCurveTaskConfig']
46 """Config class for photon transfer curve measurement task"""
47 ccdKey = pexConfig.Field(
49 doc=
"The key by which to pull a detector from a dataId, e.g. 'ccd' or 'detector'.",
52 ptcFitType = pexConfig.ChoiceField(
54 doc=
"Fit PTC to Eq. 16, Eq. 20 in Astier+19, or to a polynomial.",
57 "POLYNOMIAL":
"n-degree polynomial (use 'polynomialFitDegree' to set 'n').",
58 "EXPAPPROXIMATION":
"Approximation in Astier+19 (Eq. 16).",
59 "FULLCOVARIANCE":
"Full covariances model in Astier+19 (Eq. 20)"
62 sigmaClipFullFitCovariancesAstier = pexConfig.Field(
64 doc=
"Sigma clip for full model fit for FULLCOVARIANCE ptcFitType ",
67 maxIterFullFitCovariancesAstier = pexConfig.Field(
69 doc=
"Maximum number of iterations in full model fit for FULLCOVARIANCE ptcFitType",
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 polynomialFitDegree = pexConfig.Field(
84 doc=
"Degree of polynomial to fit the PTC, when 'ptcFitType'=POLYNOMIAL.",
87 linearity = pexConfig.ConfigurableField(
88 target=LinearitySolveTask,
89 doc=
"Task to solve the linearity."
92 doCreateLinearizer = pexConfig.Field(
94 doc=
"Calculate non-linearity and persist linearizer?",
98 binSize = pexConfig.Field(
100 doc=
"Bin the image by this factor in both dimensions.",
103 minMeanSignal = pexConfig.DictField(
106 doc=
"Minimum values (inclusive) of mean signal (in ADU) above which to consider, per amp."
107 " The same cut is applied to all amps if this dictionary is of the form"
108 " {'ALL_AMPS': value}",
109 default={
'ALL_AMPS': 0.0},
111 maxMeanSignal = pexConfig.DictField(
114 doc=
"Maximum values (inclusive) of mean signal (in ADU) below which to consider, per amp."
115 " The same cut is applied to all amps if this dictionary is of the form"
116 " {'ALL_AMPS': value}",
117 default={
'ALL_AMPS': 1e6},
119 initialNonLinearityExclusionThresholdPositive = pexConfig.RangeField(
121 doc=
"Initially exclude data points with a variance that are more than a factor of this from being"
122 " linear in the positive direction, from the PTC fit. Note that these points will also be"
123 " excluded from the non-linearity fit. This is done before the iterative outlier rejection,"
124 " to allow an accurate determination of the sigmas for said iterative fit.",
129 initialNonLinearityExclusionThresholdNegative = pexConfig.RangeField(
131 doc=
"Initially exclude data points with a variance that are more than a factor of this from being"
132 " linear in the negative direction, from the PTC fit. Note that these points will also be"
133 " excluded from the non-linearity fit. This is done before the iterative outlier rejection,"
134 " to allow an accurate determination of the sigmas for said iterative fit.",
139 sigmaCutPtcOutliers = pexConfig.Field(
141 doc=
"Sigma cut for outlier rejection in PTC.",
144 maskNameList = pexConfig.ListField(
146 doc=
"Mask list to exclude from statistics calculations.",
147 default=[
'SUSPECT',
'BAD',
'NO_DATA'],
149 nSigmaClipPtc = pexConfig.Field(
151 doc=
"Sigma cut for afwMath.StatisticsControl()",
154 nIterSigmaClipPtc = pexConfig.Field(
156 doc=
"Number of sigma-clipping iterations for afwMath.StatisticsControl()",
159 maxIterationsPtcOutliers = pexConfig.Field(
161 doc=
"Maximum number of iterations for outlier rejection in PTC.",
164 doFitBootstrap = pexConfig.Field(
166 doc=
"Use bootstrap for the PTC fit parameters and errors?.",
169 doPhotodiode = pexConfig.Field(
171 doc=
"Apply a correction based on the photodiode readings if available?",
174 photodiodeDataPath = pexConfig.Field(
176 doc=
"Gen2 only: path to locate the data photodiode data files.",
179 instrumentName = pexConfig.Field(
181 doc=
"Instrument name.",
187 """A class to calculate, fit, and plot a PTC from a set of flat pairs.
189 The Photon Transfer Curve (var(signal) vs mean(signal)) is a standard tool
190 used in astronomical detectors characterization (e.g., Janesick 2001,
191 Janesick 2007). If ptcFitType is "EXPAPPROXIMATION" or "POLYNOMIAL", this task calculates the
192 PTC from a series of pairs of flat-field images; each pair taken at identical exposure
193 times. The difference image of each pair is formed to eliminate fixed pattern noise,
194 and then the variance of the difference image and the mean of the average image
195 are used to produce the PTC. An n-degree polynomial or the approximation in Equation
196 16 of Astier+19 ("The Shape of the Photon Transfer Curve of CCD sensors",
197 arXiv:1905.08677) can be fitted to the PTC curve. These models include
198 parameters such as the gain (e/DN) and readout noise.
200 Linearizers to correct for signal-chain non-linearity are also calculated.
201 The `Linearizer` class, in general, can support per-amp linearizers, but in this
202 task this is not supported.
204 If ptcFitType is "FULLCOVARIANCE", the covariances of the difference images are calculated via the
205 DFT methods described in Astier+19 and the variances for the PTC are given by the cov[0,0] elements
206 at each signal level. The full model in Equation 20 of Astier+19 is fit to the PTC to get the gain
213 Positional arguments passed to the Task constructor. None used at this
216 Keyword arguments passed on to the Task constructor. None used at this
221 RunnerClass = DataRefListRunner
222 ConfigClass = MeasurePhotonTransferCurveTaskConfig
223 _DefaultName =
"measurePhotonTransferCurve"
226 pipeBase.CmdLineTask.__init__(self, *args, **kwargs)
227 self.makeSubtask(
"linearity")
228 plt.interactive(
False)
234 """Run the Photon Transfer Curve (PTC) measurement task.
236 For a dataRef (which is each detector here),
237 and given a list of exposure pairs (postISR) at different exposure times,
242 dataRefList : `list` [`lsst.daf.peristence.ButlerDataRef`]
243 Data references for exposures for detectors to process.
245 if len(dataRefList) < 2:
246 raise RuntimeError(
"Insufficient inputs to combine.")
249 dataRef = dataRefList[0]
251 detNum = dataRef.dataId[self.config.ccdKey]
252 camera = dataRef.get(
'camera')
253 detector = camera[dataRef.dataId[self.config.ccdKey]]
255 amps = detector.getAmplifiers()
256 ampNames = [amp.getName()
for amp
in amps]
262 for (exp1, exp2)
in expPairs.values():
263 id1 = exp1.getInfo().getVisitInfo().getExposureId()
264 id2 = exp2.getInfo().getVisitInfo().getExposureId()
265 expIds.append((id1, id2))
266 self.log.
info(f
"Measuring PTC using {expIds} exposures for detector {detector.getId()}")
271 if self.config.doPhotodiode:
272 for (expId1, expId2)
in expIds:
274 for i, expId
in enumerate([expId1, expId2]):
279 dataRef.dataId[
'expId'] = expId//1000
280 if self.config.photodiodeDataPath:
285 charges[i] = photodiodeData.getCharge()
289 self.log.
warn(f
"No photodiode data found for {expId}")
291 for ampName
in ampNames:
292 datasetPtc.photoCharge[ampName].
append((charges[0], charges[1]))
296 for ampName
in ampNames:
297 datasetPtc.photoCharge[ampName] = np.repeat(np.nan, len(expIds))
299 for ampName
in ampNames:
300 datasetPtc.inputExpIdPairs[ampName] = expIds
302 maxMeanSignalDict = {ampName: 1e6
for ampName
in ampNames}
303 minMeanSignalDict = {ampName: 0.0
for ampName
in ampNames}
304 for ampName
in ampNames:
305 if 'ALL_AMPS' in self.config.maxMeanSignal:
306 maxMeanSignalDict[ampName] = self.config.maxMeanSignal[
'ALL_AMPS']
307 elif ampName
in self.config.maxMeanSignal:
308 maxMeanSignalDict[ampName] = self.config.maxMeanSignal[ampName]
310 if 'ALL_AMPS' in self.config.minMeanSignal:
311 minMeanSignalDict[ampName] = self.config.minMeanSignal[
'ALL_AMPS']
312 elif ampName
in self.config.minMeanSignal:
313 minMeanSignalDict[ampName] = self.config.minMeanSignal[ampName]
317 for expTime, (exp1, exp2)
in expPairs.items():
318 expId1 = exp1.getInfo().getVisitInfo().getExposureId()
319 expId2 = exp2.getInfo().getVisitInfo().getExposureId()
322 tags = [
'mu',
'i',
'j',
'var',
'cov',
'npix',
'ext',
'expTime',
'ampName']
323 for ampNumber, amp
in enumerate(detector):
324 ampName = amp.getName()
326 doRealSpace = self.config.covAstierRealSpace
327 muDiff, varDiff, covAstier = self.
measureMeanVarCov(exp1, exp2, region=amp.getBBox(),
328 covAstierRealSpace=doRealSpace)
330 if np.isnan(muDiff)
or np.isnan(varDiff)
or (covAstier
is None):
331 msg = (f
"NaN mean or var, or None cov in amp {ampName} in exposure pair {expId1},"
332 f
" {expId2} of detector {detNum}.")
336 if (muDiff <= minMeanSignalDict[ampName])
or (muDiff >= maxMeanSignalDict[ampName]):
339 datasetPtc.rawExpTimes[ampName].
append(expTime)
340 datasetPtc.rawMeans[ampName].
append(muDiff)
341 datasetPtc.rawVars[ampName].
append(varDiff)
343 tupleRows += [(muDiff, ) + covRow + (ampNumber, expTime, ampName)
for covRow
in covAstier]
344 if nAmpsNan == len(ampNames):
345 msg = f
"NaN mean in all amps of exposure pair {expId1}, {expId2} of detector {detNum}."
349 tupleRecords += tupleRows
350 covariancesWithTags = np.core.records.fromrecords(tupleRecords, names=allTags)
352 for ampName
in datasetPtc.ampNames:
353 index = np.argsort(datasetPtc.rawMeans[ampName])
354 datasetPtc.rawExpTimes[ampName] = np.array(datasetPtc.rawExpTimes[ampName])[index]
355 datasetPtc.rawMeans[ampName] = np.array(datasetPtc.rawMeans[ampName])[index]
356 datasetPtc.rawVars[ampName] = np.array(datasetPtc.rawVars[ampName])[index]
358 if self.config.ptcFitType
in [
"FULLCOVARIANCE", ]:
361 elif self.config.ptcFitType
in [
"EXPAPPROXIMATION",
"POLYNOMIAL"]:
364 datasetPtc = self.
fitPtc(datasetPtc, self.config.ptcFitType)
366 detName = detector.getName()
367 now = datetime.datetime.utcnow()
368 calibDate = now.strftime(
"%Y-%m-%d")
369 butler = dataRef.getButler()
371 datasetPtc.updateMetadata(setDate=
True, camera=camera, detector=detector)
374 if self.config.doCreateLinearizer:
380 dimensions = {
'camera': camera.getName(),
'detector': detector.getId()}
381 linearityResults = self.linearity.
run(datasetPtc, camera, dimensions)
382 linearizer = linearityResults.outputLinearizer
384 self.log.
info(
"Writing linearizer:")
386 detName = detector.getName()
387 now = datetime.datetime.utcnow()
388 calibDate = now.strftime(
"%Y-%m-%d")
390 butler.put(linearizer, datasetType=
'linearizer',
391 dataId={
'detector': detNum,
'detectorName': detName,
'calibDate': calibDate})
393 self.log.
info(f
"Writing PTC data.")
394 butler.put(datasetPtc, datasetType=
'photonTransferCurveDataset', dataId={
'detector': detNum,
395 'detectorName': detName,
'calibDate': calibDate})
397 return pipeBase.Struct(exitStatus=0)
400 """Produce a list of flat pairs indexed by exposure time.
404 dataRefList : `list` [`lsst.daf.peristence.ButlerDataRef`]
405 Data references for exposures for detectors to process.
409 flatPairs : `dict` [`float`, `lsst.afw.image.exposure.exposure.ExposureF`]
410 Dictionary that groups flat-field exposures that have the same exposure time (seconds).
414 We use the difference of one pair of flat-field images taken at the same exposure time when
415 calculating the PTC to reduce Fixed Pattern Noise. If there are > 2 flat-field images with the
416 same exposure time, the first two are kept and the rest discarded.
421 for dataRef
in dataRefList:
423 tempFlat = dataRef.get(
"postISRCCD")
425 self.log.
warn(
"postISR exposure could not be retrieved. Ignoring flat.")
427 expDate = tempFlat.getInfo().getVisitInfo().getDate().get()
428 expDict.setdefault(expDate, tempFlat)
429 sortedExps = {k: expDict[k]
for k
in sorted(expDict)}
432 for exp
in sortedExps:
433 tempFlat = sortedExps[exp]
434 expTime = tempFlat.getInfo().getVisitInfo().getExposureTime()
435 listAtExpTime = flatPairs.setdefault(expTime, [])
436 if len(listAtExpTime) >= 2:
437 self.log.
warn(f
"Already found 2 exposures at expTime {expTime}. "
438 f
"Ignoring exposure {tempFlat.getInfo().getVisitInfo().getExposureId()}")
440 listAtExpTime.append(tempFlat)
443 for (key, value)
in flatPairs.items():
445 keysToDrop.append(key)
448 for key
in keysToDrop:
449 self.log.
warn(f
"Only one exposure found at expTime {key}. Dropping exposure "
450 f
"{flatPairs[key][0].getInfo().getVisitInfo().getExposureId()}.")
452 sortedFlatPairs = {k: flatPairs[k]
for k
in sorted(flatPairs)}
453 return sortedFlatPairs
456 """Fit measured flat covariances to full model in Astier+19.
460 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
461 The dataset containing information such as the means, variances and exposure times.
463 covariancesWithTagsArray : `numpy.recarray`
464 Tuple with at least (mu, cov, var, i, j, npix), where:
465 mu : 0.5*(m1 + m2), where:
466 mu1: mean value of flat1
467 mu2: mean value of flat2
468 cov: covariance value at lag(i, j)
469 var: variance(covariance value at lag(0, 0))
472 npix: number of pixels used for covariance calculation.
476 dataset: `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
477 This is the same dataset as the input paramter, however, it has been modified
478 to include information such as the fit vectors and the fit parameters. See
479 the class `PhotonTransferCurveDatase`.
482 covFits, covFitsNoB =
fitData(covariancesWithTagsArray,
483 r=self.config.maximumRangeCovariancesAstier,
484 nSigmaFullFit=self.config.sigmaClipFullFitCovariancesAstier,
485 maxIterFullFit=self.config.maxIterFullFitCovariancesAstier)
492 """Get output data for PhotonTransferCurveCovAstierDataset from CovFit objects.
496 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
497 The dataset containing information such as the means, variances and exposure times.
500 Dictionary of CovFit objects, with amp names as keys.
503 Dictionary of CovFit objects, with amp names as keys, and 'b=0' in Eq. 20 of Astier+19.
507 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
508 This is the same dataset as the input paramter, however, it has been modified
509 to include extra information such as the mask 1D array, gains, reoudout noise, measured signal,
510 measured variance, modeled variance, a, and b coefficient matrices (see Astier+19) per amplifier.
511 See the class `PhotonTransferCurveDatase`.
513 assert(len(covFits) == len(covFitsNoB))
515 for i, amp
in enumerate(dataset.ampNames):
516 lenInputTimes = len(dataset.rawExpTimes[amp])
518 dataset.ptcFitPars[amp] = np.nan
519 dataset.ptcFitParsError[amp] = np.nan
520 dataset.ptcFitChiSq[amp] = np.nan
521 if (amp
in covFits
and (covFits[amp].covParams
is not None)
and
522 (covFitsNoB[amp].covParams
is not None)):
524 fitNoB = covFitsNoB[amp]
526 dataset.covariances[amp] = fit.cov
527 dataset.covariancesModel[amp] = fit.evalCovModel()
528 dataset.covariancesSqrtWeights[amp] = fit.sqrtW
529 dataset.aMatrix[amp] = fit.getA()
530 dataset.bMatrix[amp] = fit.getB()
531 dataset.covariancesNoB[amp] = fitNoB.cov
532 dataset.covariancesModelNoB[amp] = fitNoB.evalCovModel()
533 dataset.covariancesSqrtWeightsNoB[amp] = fitNoB.sqrtW
534 dataset.aMatrixNoB[amp] = fitNoB.getA()
536 (meanVecFinal, varVecFinal, varVecModel,
537 wc, varMask) = fit.getFitData(0, 0, divideByMu=
False, returnMasked=
True)
541 dataset.expIdMask[amp] = varMask
542 dataset.gain[amp] = gain
543 dataset.gainErr[amp] = fit.getGainErr()
544 dataset.noise[amp] = np.sqrt(fit.getRon())
545 dataset.noiseErr[amp] = fit.getRonErr()
547 padLength = lenInputTimes - len(varVecFinal)
548 dataset.finalVars[amp] = np.pad(varVecFinal/(gain**2), (0, padLength),
'constant',
549 constant_values=np.nan)
550 dataset.finalModelVars[amp] = np.pad(varVecModel/(gain**2), (0, padLength),
'constant',
551 constant_values=np.nan)
552 dataset.finalMeans[amp] = np.pad(meanVecFinal/gain, (0, padLength),
'constant',
553 constant_values=np.nan)
557 matrixSide = self.config.maximumRangeCovariancesAstier
558 nanMatrix = np.full((matrixSide, matrixSide), np.nan)
559 listNanMatrix = np.full((lenInputTimes, matrixSide, matrixSide), np.nan)
561 dataset.covariances[amp] = listNanMatrix
562 dataset.covariancesModel[amp] = listNanMatrix
563 dataset.covariancesSqrtWeights[amp] = listNanMatrix
564 dataset.aMatrix[amp] = nanMatrix
565 dataset.bMatrix[amp] = nanMatrix
566 dataset.covariancesNoB[amp] = listNanMatrix
567 dataset.covariancesModelNoB[amp] = listNanMatrix
568 dataset.covariancesSqrtWeightsNoB[amp] = listNanMatrix
569 dataset.aMatrixNoB[amp] = nanMatrix
571 dataset.expIdMask[amp] = np.repeat(np.nan, lenInputTimes)
572 dataset.gain[amp] = np.nan
573 dataset.gainErr[amp] = np.nan
574 dataset.noise[amp] = np.nan
575 dataset.noiseErr[amp] = np.nan
576 dataset.finalVars[amp] = np.repeat(np.nan, lenInputTimes)
577 dataset.finalModelVars[amp] = np.repeat(np.nan, lenInputTimes)
578 dataset.finalMeans[amp] = np.repeat(np.nan, lenInputTimes)
583 """Calculate the mean of each of two exposures and the variance and covariance of their difference.
585 The variance is calculated via afwMath, and the covariance via the methods in Astier+19 (appendix A).
586 In theory, var = covariance[0,0]. This should be validated, and in the future, we may decide to just
587 keep one (covariance).
591 exposure1 : `lsst.afw.image.exposure.exposure.ExposureF`
592 First exposure of flat field pair.
594 exposure2 : `lsst.afw.image.exposure.exposure.ExposureF`
595 Second exposure of flat field pair.
597 region : `lsst.geom.Box2I`, optional
598 Region of each exposure where to perform the calculations (e.g, an amplifier).
600 covAstierRealSpace : `bool`, optional
601 Should the covariannces in Astier+19 be calculated in real space or via FFT?
602 See Appendix A of Astier+19.
606 mu : `float` or `NaN`
607 0.5*(mu1 + mu2), where mu1, and mu2 are the clipped means of the regions in
608 both exposures. If either mu1 or m2 are NaN's, the returned value is NaN.
610 varDiff : `float` or `NaN`
611 Half of the clipped variance of the difference of the regions inthe two input
612 exposures. If either mu1 or m2 are NaN's, the returned value is NaN.
614 covDiffAstier : `list` or `NaN`
615 List with tuples of the form (dx, dy, var, cov, npix), where:
621 Variance at (dx, dy).
623 Covariance at (dx, dy).
625 Number of pixel pairs used to evaluate var and cov.
626 If either mu1 or m2 are NaN's, the returned value is NaN.
629 if region
is not None:
630 im1Area = exposure1.maskedImage[region]
631 im2Area = exposure2.maskedImage[region]
633 im1Area = exposure1.maskedImage
634 im2Area = exposure2.maskedImage
636 if self.config.binSize > 1:
640 im1MaskVal = exposure1.getMask().getPlaneBitMask(self.config.maskNameList)
642 self.config.nIterSigmaClipPtc,
644 im1StatsCtrl.setNanSafe(
True)
645 im1StatsCtrl.setAndMask(im1MaskVal)
647 im2MaskVal = exposure2.getMask().getPlaneBitMask(self.config.maskNameList)
649 self.config.nIterSigmaClipPtc,
651 im2StatsCtrl.setNanSafe(
True)
652 im2StatsCtrl.setAndMask(im2MaskVal)
657 if np.isnan(mu1)
or np.isnan(mu2):
658 return np.nan, np.nan,
None
663 temp = im2Area.clone()
665 diffIm = im1Area.clone()
670 diffImMaskVal = diffIm.getMask().getPlaneBitMask(self.config.maskNameList)
672 self.config.nIterSigmaClipPtc,
674 diffImStatsCtrl.setNanSafe(
True)
675 diffImStatsCtrl.setAndMask(diffImMaskVal)
680 w1 = np.where(im1Area.getMask().getArray() == 0, 1, 0)
681 w2 = np.where(im2Area.getMask().getArray() == 0, 1, 0)
684 wDiff = np.where(diffIm.getMask().getArray() == 0, 1, 0)
687 maxRangeCov = self.config.maximumRangeCovariancesAstier
688 if covAstierRealSpace:
689 covDiffAstier =
computeCovDirect(diffIm.getImage().getArray(), w, maxRangeCov)
691 shapeDiff = diffIm.getImage().getArray().shape
692 fftShape = (
fftSize(shapeDiff[0] + maxRangeCov),
fftSize(shapeDiff[1]+maxRangeCov))
693 c =
CovFft(diffIm.getImage().getArray(), w, fftShape, maxRangeCov)
694 covDiffAstier = c.reportCovFft(maxRangeCov)
696 return mu, varDiff, covDiffAstier
699 """Compute covariances of diffImage in real space.
701 For lags larger than ~25, it is slower than the FFT way.
702 Taken from https://github.com/PierreAstier/bfptc/
706 diffImage : `numpy.array`
707 Image to compute the covariance of.
709 weightImage : `numpy.array`
710 Weight image of diffImage (1's and 0's for good and bad pixels, respectively).
713 Last index of the covariance to be computed.
718 List with tuples of the form (dx, dy, var, cov, npix), where:
724 Variance at (dx, dy).
726 Covariance at (dx, dy).
728 Number of pixel pairs used to evaluate var and cov.
733 for dy
in range(maxRange + 1):
734 for dx
in range(0, maxRange + 1):
737 cov2, nPix2 = self.
covDirectValue(diffImage, weightImage, dx, -dy)
738 cov = 0.5*(cov1 + cov2)
742 if (dx == 0
and dy == 0):
744 outList.append((dx, dy, var, cov, nPix))
749 """Compute covariances of diffImage in real space at lag (dx, dy).
751 Taken from https://github.com/PierreAstier/bfptc/ (c.f., appendix of Astier+19).
755 diffImage : `numpy.array`
756 Image to compute the covariance of.
758 weightImage : `numpy.array`
759 Weight image of diffImage (1's and 0's for good and bad pixels, respectively).
770 Covariance at (dx, dy)
773 Number of pixel pairs used to evaluate var and cov.
775 (nCols, nRows) = diffImage.shape
779 (dx, dy) = (-dx, -dy)
783 im1 = diffImage[dy:, dx:]
784 w1 = weightImage[dy:, dx:]
785 im2 = diffImage[:nCols - dy, :nRows - dx]
786 w2 = weightImage[:nCols - dy, :nRows - dx]
788 im1 = diffImage[:nCols + dy, dx:]
789 w1 = weightImage[:nCols + dy, dx:]
790 im2 = diffImage[-dy:, :nRows - dx]
791 w2 = weightImage[-dy:, :nRows - dx]
797 s1 = im1TimesW.sum()/nPix
798 s2 = (im2*wAll).sum()/nPix
799 p = (im1TimesW*im2).sum()/nPix
805 def _initialParsForPolynomial(order):
807 pars = np.zeros(order, dtype=np.float)
814 def _boundsForPolynomial(initialPars, lowers=[], uppers=[]):
816 lowers = [np.NINF
for p
in initialPars]
818 uppers = [np.inf
for p
in initialPars]
820 return (lowers, uppers)
823 def _boundsForAstier(initialPars, lowers=[], uppers=[]):
825 lowers = [np.NINF
for p
in initialPars]
827 uppers = [np.inf
for p
in initialPars]
828 return (lowers, uppers)
831 def _getInitialGoodPoints(means, variances, maxDeviationPositive, maxDeviationNegative):
832 """Return a boolean array to mask bad points.
836 means : `numpy.array`
837 Input array with mean signal values.
839 variances : `numpy.array`
840 Input array with variances at each mean value.
842 maxDeviationPositive : `float`
843 Maximum deviation from being constant for the variance/mean
844 ratio, in the positive direction.
846 maxDeviationNegative : `float`
847 Maximum deviation from being constant for the variance/mean
848 ratio, in the negative direction.
852 goodPoints : `numpy.array` [`bool`]
853 Boolean array to select good (`True`) and bad (`False`)
858 A linear function has a constant ratio, so find the median
859 value of the ratios, and exclude the points that deviate
860 from that by more than a factor of maxDeviationPositive/negative.
861 Asymmetric deviations are supported as we expect the PTC to turn
862 down as the flux increases, but sometimes it anomalously turns
863 upwards just before turning over, which ruins the fits, so it
864 is wise to be stricter about restricting positive outliers than
867 Too high and points that are so bad that fit will fail will be included
868 Too low and the non-linear points will be excluded, biasing the NL fit."""
870 assert(len(means) == len(variances))
871 ratios = [b/a
for (a, b)
in zip(means, variances)]
872 medianRatio = np.nanmedian(ratios)
873 ratioDeviations = [(r/medianRatio)-1
for r
in ratios]
876 maxDeviationPositive =
abs(maxDeviationPositive)
877 maxDeviationNegative = -1. *
abs(maxDeviationNegative)
879 goodPoints = np.array([
True if (r < maxDeviationPositive
and r > maxDeviationNegative)
880 else False for r
in ratioDeviations])
883 def _makeZeroSafe(self, array, warn=True, substituteValue=1e-9):
885 nBad = Counter(array)[0]
890 msg = f
"Found {nBad} zeros in array at elements {[x for x in np.where(array==0)[0]]}"
893 array[array == 0] = substituteValue
897 """Fit the photon transfer curve to a polynimial or to Astier+19 approximation.
899 Fit the photon transfer curve with either a polynomial of the order
900 specified in the task config, or using the Astier approximation.
902 Sigma clipping is performed iteratively for the fit, as well as an
903 initial clipping of data points that are more than
904 config.initialNonLinearityExclusionThreshold away from lying on a
905 straight line. This other step is necessary because the photon transfer
906 curve turns over catastrophically at very high flux (because saturation
907 drops the variance to ~0) and these far outliers cause the initial fit
908 to fail, meaning the sigma cannot be calculated to perform the
913 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
914 The dataset containing the means, variances and exposure times
917 Fit a 'POLYNOMIAL' (degree: 'polynomialFitDegree') or
918 'EXPAPPROXIMATION' (Eq. 16 of Astier+19) to the PTC
922 dataset: `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
923 This is the same dataset as the input paramter, however, it has been modified
924 to include information such as the fit vectors and the fit parameters. See
925 the class `PhotonTransferCurveDatase`.
928 matrixSide = self.config.maximumRangeCovariancesAstier
929 nanMatrix = np.empty((matrixSide, matrixSide))
930 nanMatrix[:] = np.nan
932 for amp
in dataset.ampNames:
933 lenInputTimes = len(dataset.rawExpTimes[amp])
934 listNanMatrix = np.empty((lenInputTimes, matrixSide, matrixSide))
935 listNanMatrix[:] = np.nan
937 dataset.covariances[amp] = listNanMatrix
938 dataset.covariancesModel[amp] = listNanMatrix
939 dataset.covariancesSqrtWeights[amp] = listNanMatrix
940 dataset.aMatrix[amp] = nanMatrix
941 dataset.bMatrix[amp] = nanMatrix
942 dataset.covariancesNoB[amp] = listNanMatrix
943 dataset.covariancesModelNoB[amp] = listNanMatrix
944 dataset.covariancesSqrtWeightsNoB[amp] = listNanMatrix
945 dataset.aMatrixNoB[amp] = nanMatrix
947 def errFunc(p, x, y):
948 return ptcFunc(p, x) - y
950 sigmaCutPtcOutliers = self.config.sigmaCutPtcOutliers
951 maxIterationsPtcOutliers = self.config.maxIterationsPtcOutliers
953 for i, ampName
in enumerate(dataset.ampNames):
954 timeVecOriginal = np.array(dataset.rawExpTimes[ampName])
955 meanVecOriginal = np.array(dataset.rawMeans[ampName])
956 varVecOriginal = np.array(dataset.rawVars[ampName])
960 self.config.initialNonLinearityExclusionThresholdPositive,
961 self.config.initialNonLinearityExclusionThresholdNegative)
962 if not (goodPoints.any()):
963 msg = (f
"\nSERIOUS: All points in goodPoints: {goodPoints} are bad."
964 f
"Setting {ampName} to BAD.")
968 dataset.badAmps.append(ampName)
969 dataset.expIdMask[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
970 dataset.gain[ampName] = np.nan
971 dataset.gainErr[ampName] = np.nan
972 dataset.noise[ampName] = np.nan
973 dataset.noiseErr[ampName] = np.nan
974 dataset.ptcFitPars[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1)
if
975 ptcFitType
in [
"POLYNOMIAL", ]
else np.repeat(np.nan, 3))
976 dataset.ptcFitParsError[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1)
if
977 ptcFitType
in [
"POLYNOMIAL", ]
else np.repeat(np.nan, 3))
978 dataset.ptcFitChiSq[ampName] = np.nan
979 dataset.finalVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
980 dataset.finalModelVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
981 dataset.finalMeans[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
986 if ptcFitType ==
'EXPAPPROXIMATION':
988 parsIniPtc = [-1e-9, 1.0, 10.]
991 uppers=[1e-4, 2.5, 100])
992 if ptcFitType ==
'POLYNOMIAL':
993 ptcFunc = funcPolynomial
999 while count <= maxIterationsPtcOutliers:
1003 meanTempVec = meanVecOriginal[mask]
1004 varTempVec = varVecOriginal[mask]
1005 res = least_squares(errFunc, parsIniPtc, bounds=bounds, args=(meanTempVec, varTempVec))
1011 sigResids = (varVecOriginal - ptcFunc(pars, meanVecOriginal))/np.sqrt(varVecOriginal)
1012 newMask = np.array([
True if np.abs(r) < sigmaCutPtcOutliers
else False for r
in sigResids])
1013 mask = mask & newMask
1014 if not (mask.any()
and newMask.any()):
1015 msg = (f
"\nSERIOUS: All points in either mask: {mask} or newMask: {newMask} are bad. "
1016 f
"Setting {ampName} to BAD.")
1020 dataset.badAmps.append(ampName)
1021 dataset.expIdMask[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1022 dataset.gain[ampName] = np.nan
1023 dataset.gainErr[ampName] = np.nan
1024 dataset.noise[ampName] = np.nan
1025 dataset.noiseErr[ampName] = np.nan
1026 dataset.ptcFitPars[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1)
1027 if ptcFitType
in [
"POLYNOMIAL", ]
else
1028 np.repeat(np.nan, 3))
1029 dataset.ptcFitParsError[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1)
1030 if ptcFitType
in [
"POLYNOMIAL", ]
else
1031 np.repeat(np.nan, 3))
1032 dataset.ptcFitChiSq[ampName] = np.nan
1033 dataset.finalVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1034 dataset.finalModelVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1035 dataset.finalMeans[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1037 nDroppedTotal = Counter(mask)[
False]
1038 self.log.
debug(f
"Iteration {count}: discarded {nDroppedTotal} points in total for {ampName}")
1041 assert (len(mask) == len(timeVecOriginal) == len(meanVecOriginal) == len(varVecOriginal))
1043 if not (mask.any()
and newMask.any()):
1045 dataset.expIdMask[ampName] = mask
1047 meanVecFinal = meanVecOriginal[mask]
1048 varVecFinal = varVecOriginal[mask]
1050 if Counter(mask)[
False] > 0:
1051 self.log.
info((f
"Number of points discarded in PTC of amplifier {ampName}:" +
1052 f
" {Counter(mask)[False]} out of {len(meanVecOriginal)}"))
1054 if (len(meanVecFinal) < len(parsIniPtc)):
1055 msg = (f
"\nSERIOUS: Not enough data points ({len(meanVecFinal)}) compared to the number of"
1056 f
"parameters of the PTC model({len(parsIniPtc)}). Setting {ampName} to BAD.")
1060 dataset.badAmps.append(ampName)
1061 dataset.expIdMask[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1062 dataset.gain[ampName] = np.nan
1063 dataset.gainErr[ampName] = np.nan
1064 dataset.noise[ampName] = np.nan
1065 dataset.noiseErr[ampName] = np.nan
1066 dataset.ptcFitPars[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1)
if
1067 ptcFitType
in [
"POLYNOMIAL", ]
else np.repeat(np.nan, 3))
1068 dataset.ptcFitParsError[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1)
if
1069 ptcFitType
in [
"POLYNOMIAL", ]
else np.repeat(np.nan, 3))
1070 dataset.ptcFitChiSq[ampName] = np.nan
1071 dataset.finalVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1072 dataset.finalModelVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1073 dataset.finalMeans[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1077 if self.config.doFitBootstrap:
1078 parsFit, parsFitErr, reducedChiSqPtc =
fitBootstrap(parsIniPtc, meanVecFinal,
1079 varVecFinal, ptcFunc,
1080 weightsY=1./np.sqrt(varVecFinal))
1082 parsFit, parsFitErr, reducedChiSqPtc =
fitLeastSq(parsIniPtc, meanVecFinal,
1083 varVecFinal, ptcFunc,
1084 weightsY=1./np.sqrt(varVecFinal))
1085 dataset.ptcFitPars[ampName] = parsFit
1086 dataset.ptcFitParsError[ampName] = parsFitErr
1087 dataset.ptcFitChiSq[ampName] = reducedChiSqPtc
1090 padLength = len(dataset.rawExpTimes[ampName]) - len(varVecFinal)
1091 dataset.finalVars[ampName] = np.pad(varVecFinal, (0, padLength),
'constant',
1092 constant_values=np.nan)
1093 dataset.finalModelVars[ampName] = np.pad(ptcFunc(parsFit, meanVecFinal), (0, padLength),
1094 'constant', constant_values=np.nan)
1095 dataset.finalMeans[ampName] = np.pad(meanVecFinal, (0, padLength),
'constant',
1096 constant_values=np.nan)
1098 if ptcFitType ==
'EXPAPPROXIMATION':
1099 ptcGain = parsFit[1]
1100 ptcGainErr = parsFitErr[1]
1101 ptcNoise = np.sqrt(np.fabs(parsFit[2]))
1102 ptcNoiseErr = 0.5*(parsFitErr[2]/np.fabs(parsFit[2]))*np.sqrt(np.fabs(parsFit[2]))
1103 if ptcFitType ==
'POLYNOMIAL':
1104 ptcGain = 1./parsFit[1]
1105 ptcGainErr = np.fabs(1./parsFit[1])*(parsFitErr[1]/parsFit[1])
1106 ptcNoise = np.sqrt(np.fabs(parsFit[0]))*ptcGain
1107 ptcNoiseErr = (0.5*(parsFitErr[0]/np.fabs(parsFit[0]))*(np.sqrt(np.fabs(parsFit[0]))))*ptcGain
1108 dataset.gain[ampName] = ptcGain
1109 dataset.gainErr[ampName] = ptcGainErr
1110 dataset.noise[ampName] = ptcNoise
1111 dataset.noiseErr[ampName] = ptcNoiseErr
1112 if not len(dataset.ptcFitType) == 0:
1113 dataset.ptcFitType = ptcFitType
1114 if len(dataset.badAmps) == 0:
1115 dataset.badAmps = np.repeat(np.nan, len(
list(dataset.rawExpTimes.values())[0]))