Coverage for python/lsst/cp/pipe/ptc/cpSolvePtcTask.py: 10%
397 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-17 11:03 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-17 11:03 +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 collections import Counter
25import lsst.pex.config as pexConfig
26import lsst.pipe.base as pipeBase
27from lsst.cp.pipe.utils import (fitLeastSq, fitBootstrap, funcPolynomial, funcAstier, symmetrize)
29from scipy.signal import fftconvolve
30from scipy.optimize import least_squares
31from itertools import groupby
32from operator import itemgetter
34import lsst.pipe.base.connectionTypes as cT
36from lsst.ip.isr import PhotonTransferCurveDataset
38from lsst.cp.pipe._lookupStaticCalibration import lookupStaticCalibration
40import copy
43__all__ = ['PhotonTransferCurveSolveConfig', 'PhotonTransferCurveSolveTask']
46class PhotonTransferCurveSolveConnections(pipeBase.PipelineTaskConnections,
47 dimensions=("instrument", "detector")):
48 inputCovariances = cT.Input(
49 name="ptcCovariances",
50 doc="Tuple with measured covariances from flats.",
51 storageClass="PhotonTransferCurveDataset",
52 dimensions=("instrument", "exposure", "detector"),
53 isCalibration=True,
54 multiple=True,
55 )
56 camera = cT.PrerequisiteInput(
57 name="camera",
58 doc="Camera the input data comes from.",
59 storageClass="Camera",
60 dimensions=("instrument",),
61 isCalibration=True,
62 lookupFunction=lookupStaticCalibration,
63 )
64 outputPtcDataset = cT.Output(
65 name="ptcDatsetProposal",
66 doc="Output proposed ptc dataset.",
67 storageClass="PhotonTransferCurveDataset",
68 dimensions=("instrument", "detector"),
69 multiple=False,
70 isCalibration=True,
71 )
74class PhotonTransferCurveSolveConfig(pipeBase.PipelineTaskConfig,
75 pipelineConnections=PhotonTransferCurveSolveConnections):
76 """Configuration for fitting measured covariances.
77 """
79 ptcFitType = pexConfig.ChoiceField(
80 dtype=str,
81 doc="Fit PTC to Eq. 16, Eq. 20 in Astier+19, or to a polynomial.",
82 default="POLYNOMIAL",
83 allowed={
84 "POLYNOMIAL": "n-degree polynomial (use 'polynomialFitDegree' to set 'n').",
85 "EXPAPPROXIMATION": "Approximation in Astier+19 (Eq. 16).",
86 "FULLCOVARIANCE": "Full covariances model in Astier+19 (Eq. 20)"
87 }
88 )
89 maximumRangeCovariancesAstier = pexConfig.Field(
90 dtype=int,
91 doc="Maximum range of covariances as in Astier+19",
92 default=8,
93 )
94 sigmaClipFullFitCovariancesAstier = pexConfig.Field(
95 dtype=float,
96 doc="sigma clip for full model fit for FULLCOVARIANCE ptcFitType ",
97 default=5.0,
98 )
99 maxIterFullFitCovariancesAstier = pexConfig.Field(
100 dtype=int,
101 doc="Maximum number of iterations in full model fit for FULLCOVARIANCE ptcFitType",
102 default=3,
103 )
104 polynomialFitDegree = pexConfig.Field(
105 dtype=int,
106 doc="Degree of polynomial to fit the PTC, when 'ptcFitType'=POLYNOMIAL.",
107 default=3,
108 )
109 sigmaCutPtcOutliers = pexConfig.Field(
110 dtype=float,
111 doc="Sigma cut for outlier rejection in PTC.",
112 default=5.0,
113 )
114 maxIterationsPtcOutliers = pexConfig.RangeField(
115 dtype=int,
116 doc="Maximum number of iterations for outlier rejection in PTC.",
117 default=2,
118 min=0
119 )
120 minVarPivotSearch = pexConfig.Field(
121 dtype=float,
122 doc="The code looks for a pivot signal point after which the variance starts decreasing at high-flux"
123 " to exclude then from the PTC model fit. However, sometimes at low fluxes, the variance"
124 " decreases slightly. Set this variable for the variance value, in ADU^2, after which the pivot "
125 " should be sought.",
126 default=10000,
127 )
128 consecutivePointsVarDecreases = pexConfig.RangeField(
129 dtype=int,
130 doc="Required number of consecutive points/fluxes in the PTC where the variance "
131 "decreases in order to find a first estimate of the PTC turn-off. ",
132 default=2,
133 min=2
134 )
135 doFitBootstrap = pexConfig.Field(
136 dtype=bool,
137 doc="Use bootstrap for the PTC fit parameters and errors?.",
138 default=False,
139 )
142class PhotonTransferCurveSolveTask(pipeBase.PipelineTask):
143 """Task to fit the PTC from flat covariances.
145 The first task of the PTC measurement pipeline,
146 ``PhotonTransferCurveMeasureTask`` (and assumed to have been run
147 before this task), produced a list of
148 `~lsst.ip.isr.PhotonTransferCurveDataset` objects. Each dataset
149 contains the mean signal and covariances of the
150 difference image of the flat-field images taken at
151 the same exposure time. The list also contains dummy
152 datasets (with no measurements), whose purpose is to have
153 the input and output dimensions of ``PhotonTransferCurveMeasureTask``
154 match.
156 This task, ``PhotonTransferCurveSolveTask``, assembles the list
157 of individual PTC datasets produced
158 by ``PhotonTransferCurveMeasureTask`` into one single final PTC
159 dataset, discarding the dummy datset as appropiate.
160 The task fits the measured (co)variances to one of three models:
161 a polynomial model of a given order, or the models described
162 in equations 16 and 20 of Astier+19. These options are referred
163 to as ``POLYNOMIAL``, ``EXPAPPROXIMATION``, and ``FULLCOVARIANCE``
164 in the configuration options of the task, respectively).
165 Parameters of interest such as the gain and noise are derived
166 from the fits. The ``FULLCOVARIANCE`` model is fitted to the
167 full covariance data (as oppossed to the other two models, which
168 are fit to the variance vs mean measurements only).
170 Astier+19: "The Shape of the Photon Transfer Curve
171 of CCD sensors", arXiv:1905.08677
172 """
174 ConfigClass = PhotonTransferCurveSolveConfig
175 _DefaultName = 'cpPhotonTransferCurveSolve'
177 def runQuantum(self, butlerQC, inputRefs, outputRefs):
178 """Ensure that the input and output dimensions are passed along.
180 Parameters
181 ----------
182 butlerQC : `~lsst.daf.butler.butlerQuantumContext.ButlerQuantumContext`
183 Butler to operate on.
184 inputRefs : `~lsst.pipe.base.connections.InputQuantizedConnection`
185 Input data refs to load.
186 ouptutRefs : `~lsst.pipe.base.connections.OutputQuantizedConnection`
187 Output data refs to persist.
188 """
189 inputs = butlerQC.get(inputRefs)
190 detId = inputRefs.inputCovariances[0].dataId['detector']
191 outputs = self.run(inputCovariances=inputs['inputCovariances'], camera=inputs['camera'], detId=detId)
192 butlerQC.put(outputs, outputRefs)
194 def run(self, inputCovariances, camera=None, detId=0):
195 """Fit measured covariances to different models.
197 Parameters
198 ----------
199 inputCovariances : `list` [`lsst.ip.isr.PhotonTransferCurveDataset`]
200 List of lsst.ip.isr.PhotonTransferCurveDataset datasets.
201 camera : `lsst.afw.cameraGeom.Camera`, optional
202 Input camera.
203 detId : `int`
204 Detector ID to locate the detector in the camera and
205 populate the `lsst.ip.isr.PhotonTransferCurveDataset`
206 metadata.
207 Returns
208 -------
209 results : `lsst.pipe.base.Struct`
210 The resultins structure contains:
212 ``outputPtcDatset``
213 Final PTC dataset, containing information such as the
214 means, variances, and exposure times
215 (`lsst.ip.isr.PhotonTransferCurveDataset`).
216 """
217 # Assemble individual PTC datasets into a single PTC dataset.
218 ampNames = np.unique(inputCovariances[0].ampNames)
219 datasetPtc = PhotonTransferCurveDataset(ampNames=ampNames,
220 ptcFitType=self.config.ptcFitType,
221 covMatrixSide=self.config.maximumRangeCovariancesAstier)
222 for partialPtcDataset in inputCovariances:
223 # Ignore dummy datasets
224 if partialPtcDataset.ptcFitType == 'DUMMY':
225 continue
226 for ampName in ampNames:
227 datasetPtc.inputExpIdPairs[ampName].append(partialPtcDataset.inputExpIdPairs[ampName])
228 if type(partialPtcDataset.rawExpTimes[ampName]) is list:
229 datasetPtc.rawExpTimes[ampName].append(partialPtcDataset.rawExpTimes[ampName][0])
230 else:
231 datasetPtc.rawExpTimes[ampName].append(partialPtcDataset.rawExpTimes[ampName])
232 if type(partialPtcDataset.rawMeans[ampName]) is list:
233 datasetPtc.rawMeans[ampName].append(partialPtcDataset.rawMeans[ampName][0])
234 else:
235 datasetPtc.rawMeans[ampName].append(partialPtcDataset.rawMeans[ampName])
236 if type(partialPtcDataset.rawVars[ampName]) is list:
237 datasetPtc.rawVars[ampName].append(partialPtcDataset.rawVars[ampName][0])
238 else:
239 datasetPtc.rawVars[ampName].append(partialPtcDataset.rawVars[ampName])
240 if type(partialPtcDataset.expIdMask[ampName]) is list:
241 datasetPtc.expIdMask[ampName].append(partialPtcDataset.expIdMask[ampName][0])
242 else:
243 datasetPtc.expIdMask[ampName].append(partialPtcDataset.expIdMask[ampName])
244 datasetPtc.covariances[ampName].append(np.array(partialPtcDataset.covariances[ampName][0]))
245 datasetPtc.covariancesSqrtWeights[ampName].append(
246 np.array(partialPtcDataset.covariancesSqrtWeights[ampName][0]))
247 # Sort arrays that are filled so far in the final dataset by
248 # rawMeans index
249 for ampName in ampNames:
250 index = np.argsort(np.ravel(np.array(datasetPtc.rawMeans[ampName])))
251 datasetPtc.inputExpIdPairs[ampName] = np.array(datasetPtc.inputExpIdPairs[ampName])[index]
252 datasetPtc.rawExpTimes[ampName] = np.array(datasetPtc.rawExpTimes[ampName])[index]
253 datasetPtc.rawMeans[ampName] = np.array(datasetPtc.rawMeans[ampName])[index]
254 datasetPtc.rawVars[ampName] = np.array(datasetPtc.rawVars[ampName])[index]
255 datasetPtc.expIdMask[ampName] = np.array(datasetPtc.expIdMask[ampName])[index]
256 datasetPtc.covariances[ampName] = np.array(datasetPtc.covariances[ampName])[index]
257 datasetPtc.covariancesSqrtWeights[ampName] = np.array(
258 datasetPtc.covariancesSqrtWeights[ampName])[index]
259 if self.config.ptcFitType == "FULLCOVARIANCE":
260 # Fit the measured covariances vs mean signal to
261 # the Astier+19 full model (Eq. 20). Before that
262 # do a preliminary fit to the variance (C_00) vs mean
263 # signal (mu) curve using the EXPAPPROXIMATION model
264 # (Eq. 16 in Astier+19) in order to
265 # get the flat pairs that are masked. The
266 # points at these fluxes will also be masked when
267 # calculating the other elements of the covariance
268 # matrix, C_ij, i!=j).
270 # Preliminary fit, usign a temp dataset to get the mask
271 tempDatasetPtc = copy.copy(datasetPtc)
272 tempDatasetPtc.ptcFitType = "EXPAPPROXIMATION"
273 tempDatasetPtc = self.fitMeasurementsToModel(tempDatasetPtc)
275 # "FULLCOVARIANCE", using the mask obtained from the
276 # previous fit.
277 for ampName in datasetPtc.ampNames:
278 datasetPtc.expIdMask[ampName] = tempDatasetPtc.expIdMask[ampName]
279 datasetPtc.fitType = "FULLCOVARIANCE"
280 datasetPtc = self.fitMeasurementsToModel(datasetPtc)
281 # The other options are: self.config.ptcFitType in
282 # ("EXPAPPROXIMATION", "POLYNOMIAL")
283 else:
284 # Fit the PTC to a polynomial or to Astier+19 exponential
285 # approximation (Eq. 16). Fill up
286 # PhotonTransferCurveDataset object.
287 datasetPtc = self.fitMeasurementsToModel(datasetPtc)
289 if camera:
290 detector = camera[detId]
291 else:
292 detector = None
293 datasetPtc.updateMetadata(setDate=True, camera=camera, detector=detector)
295 return pipeBase.Struct(
296 outputPtcDataset=datasetPtc,
297 )
299 def fitMeasurementsToModel(self, dataset):
300 """Fit the measured covariances vs mean signal to a
301 polynomial or one of the models in Astier+19
302 (Eq. 16 or Eq.20).
304 Parameters
305 ----------
306 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
307 The dataset containing information such as the means,
308 (co)variances, and exposure times.
310 Returns
311 -------
312 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
313 This is the same dataset as the input parameter, however,
314 it has been modified to include information such as the
315 fit vectors and the fit parameters. See the class
316 `PhotonTransferCurveDatase`.
317 """
318 fitType = dataset.ptcFitType
319 if fitType in ["FULLCOVARIANCE", ]:
320 # This model uses the full covariance matrix in the fit.
321 # The PTC is technically defined as variance vs signal,
322 # with variance = Cov_00
323 dataset = self.fitDataFullCovariance(dataset)
324 elif fitType in ["POLYNOMIAL", "EXPAPPROXIMATION"]:
325 # The PTC is technically defined as variance vs signal
326 dataset = self.fitPtc(dataset)
327 else:
328 raise RuntimeError(
329 f"Fitting option {fitType} not one of "
330 "'POLYNOMIAL', 'EXPAPPROXIMATION', or 'FULLCOVARIANCE'"
331 )
333 return dataset
335 def fitDataFullCovariance(self, dataset):
336 """Fit measured flat covariances to the full model in
337 Astier+19 (Eq. 20).
339 Parameters
340 ----------
341 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
342 The dataset containing information such as the means,
343 (co)variances, and exposure times.
345 Returns
346 -------
347 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
348 This is the same dataset as the input parameter, however,
349 it has been modified to include information such as the
350 fit vectors and the fit parameters. See the class
351 `PhotonTransferCurveDatase`.
353 Notes
354 -----
355 The parameters of the full model for C_ij(mu) ("C_ij" and "mu"
356 in ADU^2 and ADU, respectively) in Astier+19 (Eq. 20) are:
358 - "a" coefficients (r by r matrix), units: 1/e
359 - "b" coefficients (r by r matrix), units: 1/e
360 - noise matrix (r by r matrix), units: e^2
361 - gain, units: e/ADU
363 "b" appears in Eq. 20 only through the "ab" combination, which
364 is defined in this code as "c=ab".
366 Total number of parameters: #entries(a) + #entries(c) + #entries(noise)
367 + 1. This is equivalent to r^2 + r^2 + r^2 + 1, where "r" is the
368 maximum lag considered for the covariances calculation, and the
369 extra "1" is the gain. If "b" is 0, then "c" is 0, and len(pInit) will
370 have r^2 fewer entries.
371 """
372 matrixSide = self.config.maximumRangeCovariancesAstier
373 lenParams = matrixSide*matrixSide
375 for ampName in dataset.ampNames:
376 lenInputTimes = len(dataset.rawExpTimes[ampName])
377 # Not used when ptcFitType is 'FULLCOVARIANCE'
378 dataset.ptcFitPars[ampName] = [np.nan]
379 dataset.ptcFitParsError[ampName] = [np.nan]
380 dataset.ptcFitChiSq[ampName] = np.nan
382 if ampName in dataset.badAmps:
383 # Bad amp
384 # Entries need to have proper dimensions so read/write
385 # with astropy.Table works.
386 nanMatrix = np.full((matrixSide, matrixSide), np.nan)
387 listNanMatrix = np.full((lenInputTimes, matrixSide, matrixSide), np.nan)
388 dataset.covariancesModel[ampName] = listNanMatrix
389 dataset.covariancesSqrtWeights[ampName] = listNanMatrix
390 dataset.aMatrix[ampName] = nanMatrix
391 dataset.bMatrix[ampName] = nanMatrix
392 dataset.covariancesModelNoB[ampName] = listNanMatrix
393 dataset.aMatrixNoB[ampName] = nanMatrix
395 dataset.expIdMask[ampName] = np.repeat(False, lenInputTimes)
396 dataset.gain[ampName] = np.nan
397 dataset.gainErr[ampName] = np.nan
398 dataset.noise[ampName] = np.nan
399 dataset.noiseErr[ampName] = np.nan
400 dataset.finalVars[ampName] = np.repeat(np.nan, lenInputTimes)
401 dataset.finalModelVars[ampName] = np.repeat(np.nan, lenInputTimes)
402 dataset.finalMeans[ampName] = np.repeat(np.nan, lenInputTimes)
403 continue
405 muAtAmp = dataset.rawMeans[ampName]
406 maskAtAmp = dataset.expIdMask[ampName]
407 if len(maskAtAmp) == 0:
408 maskAtAmp = np.repeat(True, len(muAtAmp))
410 muAtAmp = muAtAmp[maskAtAmp]
411 covAtAmp = np.nan_to_num(dataset.covariances[ampName])[maskAtAmp]
412 covSqrtWeightsAtAmp = np.nan_to_num(dataset.covariancesSqrtWeights[ampName])[maskAtAmp]
414 # Initial fit, to approximate parameters, with c=0
415 a0, c0, noise0, gain0 = self.initialFitFullCovariance(muAtAmp, covAtAmp, covSqrtWeightsAtAmp)
417 # Fit full model (Eq. 20 of Astier+19) and same model with
418 # b=0 (c=0 in this code)
419 pInit = np.concatenate((a0.flatten(), c0.flatten(), noise0.flatten(), np.array(gain0)), axis=None)
420 functionsDict = {'fullModel': self.funcFullCovarianceModel,
421 'fullModelNoB': self.funcFullCovarianceModelNoB}
422 fitResults = {'fullModel': {'a': [], 'c': [], 'noise': [], 'gain': [], 'paramsErr': []},
423 'fullModelNoB': {'a': [], 'c': [], 'noise': [], 'gain': [], 'paramsErr': []}}
424 for key in functionsDict:
425 params, paramsErr, _ = fitLeastSq(pInit, muAtAmp,
426 covAtAmp.flatten(), functionsDict[key],
427 weightsY=covSqrtWeightsAtAmp.flatten())
428 a = params[:lenParams].reshape((matrixSide, matrixSide))
429 c = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide))
430 noise = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide))
431 gain = params[-1]
433 fitResults[key]['a'] = a
434 fitResults[key]['c'] = c
435 fitResults[key]['noise'] = noise
436 fitResults[key]['gain'] = gain
437 fitResults[key]['paramsErr'] = paramsErr
439 # Put the information in the PTC dataset
441 # Not used when ptcFitType is 'FULLCOVARIANCE'
442 dataset.ptcFitPars[ampName] = [np.nan]
443 dataset.ptcFitParsError[ampName] = [np.nan]
444 dataset.ptcFitChiSq[ampName] = np.nan
446 # Save full covariances, covariances models, and their weights.
447 # dataset.expIdMask is already full, but needs to be
448 # converted to bool.
449 dataset.expIdMask[ampName] = np.array(dataset.expIdMask[ampName], dtype=bool)
450 dataset.covariances[ampName] = covAtAmp
451 dataset.covariancesModel[ampName] = self.evalCovModel(muAtAmp,
452 fitResults['fullModel']['a'],
453 fitResults['fullModel']['c'],
454 fitResults['fullModel']['noise'],
455 fitResults['fullModel']['gain'])
456 dataset.covariancesSqrtWeights[ampName] = covSqrtWeightsAtAmp
457 dataset.aMatrix[ampName] = fitResults['fullModel']['a']
458 dataset.bMatrix[ampName] = fitResults['fullModel']['c']/fitResults['fullModel']['a']
459 dataset.covariancesModelNoB[ampName] = self.evalCovModel(muAtAmp,
460 fitResults['fullModelNoB']['a'],
461 fitResults['fullModelNoB']['c'],
462 fitResults['fullModelNoB']['noise'],
463 fitResults['fullModelNoB']['gain'],
464 setBtoZero=True)
465 dataset.aMatrixNoB[ampName] = fitResults['fullModelNoB']['a']
466 dataset.gain[ampName] = fitResults['fullModel']['gain']
467 dataset.gainErr[ampName] = fitResults['fullModel']['paramsErr'][-1]
468 readoutNoise = fitResults['fullModel']['noise'][0][0]
469 readoutNoiseSqrt = np.sqrt(np.fabs(readoutNoise))
470 dataset.noise[ampName] = readoutNoise
471 readoutNoiseSigma = fitResults['fullModel']['paramsErr'][2*lenParams]
472 dataset.noiseErr[ampName] = 0.5*(readoutNoiseSigma/np.fabs(readoutNoise))*readoutNoiseSqrt
473 dataset.finalVars[ampName] = covAtAmp[:, 0, 0]
474 dataset.finalModelVars[ampName] = dataset.covariancesModel[ampName][:, 0, 0]
475 dataset.finalMeans[ampName] = muAtAmp
477 return dataset
479 def initialFitFullCovariance(self, mu, cov, sqrtW):
480 """ Performs a crude parabolic fit of the data in order to start
481 the full fit close to the solution, setting b=0 (c=0) in Eq. 20
482 of Astier+19.
484 Parameters
485 ----------
486 mu : `numpy.array`, (N,)
487 Signal `mu` (ADU)
488 cov : `numpy.array`, (N, M, M)
489 Covariance arrays of size `(M, M)` (with
490 `M = config.maximumRangeCovariancesAstier`),
491 indexed by mean signal `mu`.
492 sqrtW : `numpy.array`, (N,)
493 Covariance weights, defined as 1./sqrt(Variances)
495 Returns
496 -------
497 a : `numpy.array`, (M, M)
498 "a" parameter per flux in Eq. 20 of Astier+19.
499 c : `numpy.array`, (M, M)
500 "c"="ab" parameter per flux in Eq. 20 of Astier+19.
501 noise : `numpy.array`, (M, M)
502 "noise" parameter per flux in Eq. 20 of Astier+19.
503 gain : `float`
504 Amplifier gain (e/ADU)
505 """
506 matrixSide = self.config.maximumRangeCovariancesAstier
508 # Initialize fit parameters
509 a = np.zeros((matrixSide, matrixSide))
510 c = np.zeros((matrixSide, matrixSide))
511 noise = np.zeros((matrixSide, matrixSide))
512 gain = 1.
514 # iterate the fit to account for higher orders
515 # the chi2 does not necessarily go down, so one could
516 # stop when it increases
517 oldChi2 = 1e30
518 for _ in range(5):
519 model = np.nan_to_num(self.evalCovModel(mu, a, c, noise, gain, setBtoZero=True))
520 # loop on lags
521 for i in range(matrixSide):
522 for j in range(matrixSide):
523 # fit a parabola for a given lag
524 parsFit = np.polyfit(mu, cov[:, i, j] - model[:, i, j],
525 2, w=sqrtW[:, i, j])
526 # model equation (Eq. 20) in Astier+19, with c=a*b=0:
527 a[i, j] += parsFit[0]
528 noise[i, j] += parsFit[2]
529 if(i + j == 0):
530 gain = 1./(1/gain+parsFit[1])
531 weightedRes = (model - cov)*sqrtW
532 chi2 = (weightedRes.flatten()**2).sum()
533 if chi2 > oldChi2:
534 break
535 oldChi2 = chi2
537 return a, c, noise, gain
539 def funcFullCovarianceModel(self, params, x):
540 """Model to fit covariances from flat fields; Equation 20 of
541 Astier+19.
543 Parameters
544 ----------
545 params : `list`
546 Parameters of the model: aMatrix, CMatrix, noiseMatrix,
547 gain (e/ADU).
548 x : `numpy.array`, (N,)
549 Signal `mu` (ADU)
551 Returns
552 -------
553 y : `numpy.array`, (N,)
554 Covariance matrix.
555 """
556 matrixSide = self.config.maximumRangeCovariancesAstier
557 lenParams = matrixSide*matrixSide
558 aMatrix = params[:lenParams].reshape((matrixSide, matrixSide))
559 cMatrix = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide))
560 noiseMatrix = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide))
561 gain = params[-1]
563 return self.evalCovModel(x, aMatrix, cMatrix, noiseMatrix, gain).flatten()
565 def funcFullCovarianceModelNoB(self, params, x):
566 """Model to fit covariances from flat fields; Equation 20 of
567 Astier+19, with b=0 (equivalent to c=a*b=0 in this code).
569 Parameters
570 ----------
571 params : `list`
572 Parameters of the model: aMatrix, CMatrix, noiseMatrix,
573 gain (e/ADU).
574 x : `numpy.array`, (N,)
575 Signal mu (ADU)
577 Returns
578 -------
579 y : `numpy.array`, (N,)
580 Covariance matrix.
581 """
582 matrixSide = self.config.maximumRangeCovariancesAstier
583 lenParams = matrixSide*matrixSide
584 aMatrix = params[:lenParams].reshape((matrixSide, matrixSide))
585 cMatrix = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide))
586 noiseMatrix = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide))
587 gain = params[-1]
589 return self.evalCovModel(x, aMatrix, cMatrix, noiseMatrix, gain, setBtoZero=True).flatten()
591 def evalCovModel(self, mu, aMatrix, cMatrix, noiseMatrix, gain, setBtoZero=False):
592 """Computes full covariances model (Eq. 20 of Astier+19).
594 Parameters
595 ----------
596 mu : `numpy.array`, (N,)
597 List of mean signals.
598 aMatrix : `numpy.array`, (M, M)
599 "a" parameter per flux in Eq. 20 of Astier+19.
600 cMatrix : `numpy.array`, (M, M)
601 "c"="ab" parameter per flux in Eq. 20 of Astier+19.
602 noiseMatrix : `numpy.array`, (M, M)
603 "noise" parameter per flux in Eq. 20 of Astier+19.
604 gain : `float`
605 Amplifier gain (e/ADU)
606 setBtoZero=False : `bool`, optional
607 Set "b" parameter in full model (see Astier+19) to zero.
609 Returns
610 -------
611 covModel : `numpy.array`, (N, M, M)
612 Covariances model.
614 Notes
615 -----
616 By default, computes the covModel for the mu's stored(self.mu).
617 Returns cov[Nmu, M, M]. The variance for the PTC is
618 cov[:, 0, 0]. mu and cov are in ADUs and ADUs squared. To use
619 electrons for both, the gain should be set to 1. This routine
620 implements the model in Astier+19 (1905.08677).
621 The parameters of the full model for C_ij(mu) ("C_ij" and "mu"
622 in ADU^2 and ADU, respectively) in Astier+19 (Eq. 20) are:
624 - "a" coefficients (M by M matrix), units: 1/e
625 - "b" coefficients (M by M matrix), units: 1/e
626 - noise matrix (M by M matrix), units: e^2
627 - gain, units: e/ADU
629 "b" appears in Eq. 20 only through the "ab" combination, which
630 is defined in this code as "c=ab".
631 """
632 matrixSide = self.config.maximumRangeCovariancesAstier
633 sa = (matrixSide, matrixSide)
634 # pad a with zeros and symmetrize
635 aEnlarged = np.zeros((int(sa[0]*1.5)+1, int(sa[1]*1.5)+1))
636 aEnlarged[0:sa[0], 0:sa[1]] = aMatrix
637 aSym = symmetrize(aEnlarged)
638 # pad c with zeros and symmetrize
639 cEnlarged = np.zeros((int(sa[0]*1.5)+1, int(sa[1]*1.5)+1))
640 cEnlarged[0:sa[0], 0:sa[1]] = cMatrix
641 cSym = symmetrize(cEnlarged)
642 a2 = fftconvolve(aSym, aSym, mode='same')
643 a3 = fftconvolve(a2, aSym, mode='same')
644 ac = fftconvolve(aSym, cSym, mode='same')
645 (xc, yc) = np.unravel_index(np.abs(aSym).argmax(), a2.shape)
647 a1 = aMatrix[np.newaxis, :, :]
648 a2 = a2[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide]
649 a3 = a3[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide]
650 ac = ac[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide]
651 c1 = cMatrix[np.newaxis, ::]
653 # assumes that mu is 1d
654 bigMu = mu[:, np.newaxis, np.newaxis]*gain
655 # c(=a*b in Astier+19) also has a contribution to the last
656 # term, that is absent for now.
657 if setBtoZero:
658 c1 = np.zeros_like(c1)
659 ac = np.zeros_like(ac)
660 covModel = (bigMu/(gain*gain)*(a1*bigMu+2./3.*(bigMu*bigMu)*(a2 + c1)
661 + (1./3.*a3 + 5./6.*ac)*(bigMu*bigMu*bigMu)) + noiseMatrix[np.newaxis, :, :]/gain**2)
662 # add the Poisson term, and the read out noise (variance)
663 covModel[:, 0, 0] += mu/gain
665 return covModel
667 # EXPAPPROXIMATION and POLYNOMIAL fit methods
668 @staticmethod
669 def _initialParsForPolynomial(order):
670 assert(order >= 2)
671 pars = np.zeros(order, dtype=float)
672 pars[0] = 10
673 pars[1] = 1
674 pars[2:] = 0.0001
675 return pars
677 @staticmethod
678 def _boundsForPolynomial(initialPars, lowers=[], uppers=[]):
679 if not len(lowers):
680 lowers = [np.NINF for p in initialPars]
681 if not len(uppers):
682 uppers = [np.inf for p in initialPars]
683 lowers[1] = 0 # no negative gains
684 return (lowers, uppers)
686 @staticmethod
687 def _boundsForAstier(initialPars, lowers=[], uppers=[]):
688 if not len(lowers):
689 lowers = [np.NINF for p in initialPars]
690 if not len(uppers):
691 uppers = [np.inf for p in initialPars]
692 return (lowers, uppers)
694 @staticmethod
695 def _getInitialGoodPoints(means, variances, minVarPivotSearch, consecutivePointsVarDecreases):
696 """Return a boolean array to mask bad points.
698 Parameters
699 ----------
700 means : `numpy.array`
701 Input array with mean signal values.
702 variances : `numpy.array`
703 Input array with variances at each mean value.
704 minVarPivotSearch : `float`
705 The variance (in ADU^2), above which, the point
706 of decreasing variance should be sought.
707 consecutivePointsVarDecreases : `int`
708 Required number of consecutive points/fluxes
709 in the PTC where the variance
710 decreases in order to find a first
711 estimate of the PTC turn-off.
713 Returns
714 ------
715 goodPoints : `numpy.array` [`bool`]
716 Boolean array to select good (`True`) and bad (`False`)
717 points.
719 Notes
720 -----
721 Eliminate points beyond which the variance decreases.
722 """
723 goodPoints = np.ones_like(means, dtype=bool)
724 # Variances are sorted and should monotonically increase
725 pivotList = np.where(np.array(np.diff(variances)) < 0)[0]
726 if len(pivotList) > 0:
727 # For small values, sometimes the variance decreases slightly
728 # Only look when var > self.config.minVarPivotSearch
729 pivotList = [p for p in pivotList if variances[p] > minVarPivotSearch]
730 # Require that the varince decreases during
731 # consecutivePointsVarDecreases
732 # consecutive points. This will give a first
733 # estimate of the PTC turn-off, which
734 # may be updated (reduced) further in the code.
735 if len(pivotList) > 1:
736 # enumerate(pivotList) creates tuples (index, value), for
737 # each value in pivotList. The lambda function subtracts
738 # each value from the index.
739 # groupby groups elements by equal key value.
740 for k, g in groupby(enumerate(pivotList), lambda x: x[0]-x[1]):
741 group = (map(itemgetter(1), g))
742 # Form groups of consecute values from pivotList
743 group = list(map(int, group))
744 # values in pivotList are indices where np.diff(variances)
745 # is negative, i.e., where the variance starts decreasing.
746 # Find the first group of consecutive numbers when
747 # variance decreases.
748 if len(group) >= consecutivePointsVarDecreases:
749 pivotIndex = np.min(group)
750 goodPoints[pivotIndex+1:] = False
751 break
753 return goodPoints
755 def _makeZeroSafe(self, array, substituteValue=1e-9):
756 """"""
757 array = np.array(array)
758 nBad = Counter(np.ravel(array))[0]
759 if nBad == 0:
760 return array
762 index, = np.where(array == 0)
763 if len(index):
764 msg = f"Found {nBad} zeros in array at elements {index}"
765 self.log.warning(msg)
767 array[index] = substituteValue
769 return array
771 def fitPtc(self, dataset):
772 """Fit the photon transfer curve to a polynomial or to the
773 Astier+19 approximation (Eq. 16).
775 Fit the photon transfer curve with either a polynomial of
776 the order specified in the task config, or using the
777 exponential approximation in Astier+19 (Eq. 16).
779 Sigma clipping is performed iteratively for the fit, as
780 well as an initial clipping of data points that are more
781 than `config.initialNonLinearityExclusionThreshold` away
782 from lying on a straight line. This other step is necessary
783 because the photon transfer curve turns over catastrophically
784 at very high flux (because saturation
785 drops the variance to ~0) and these far outliers cause the
786 initial fit to fail, meaning the sigma cannot be calculated
787 to perform the sigma-clipping.
789 Parameters
790 ----------
791 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
792 The dataset containing the means, variances and
793 exposure times.
795 Returns
796 -------
797 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
798 This is the same dataset as the input parameter, however,
799 it has been modified to include information such as the
800 fit vectors and the fit parameters. See the class
801 `PhotonTransferCurveDatase`.
803 Raises
804 ------
805 RuntimeError
806 Raised if dataset.ptcFitType is None or empty.
807 """
808 if dataset.ptcFitType:
809 ptcFitType = dataset.ptcFitType
810 else:
811 raise RuntimeError("ptcFitType is None of empty in PTC dataset.")
812 matrixSide = self.config.maximumRangeCovariancesAstier
813 nanMatrix = np.empty((matrixSide, matrixSide))
814 nanMatrix[:] = np.nan
816 for amp in dataset.ampNames:
817 lenInputTimes = len(dataset.rawExpTimes[amp])
818 listNanMatrix = np.empty((lenInputTimes, matrixSide, matrixSide))
819 listNanMatrix[:] = np.nan
821 dataset.covariancesModel[amp] = listNanMatrix
822 dataset.aMatrix[amp] = nanMatrix
823 dataset.bMatrix[amp] = nanMatrix
824 dataset.covariancesModelNoB[amp] = listNanMatrix
825 dataset.aMatrixNoB[amp] = nanMatrix
827 def errFunc(p, x, y):
828 return ptcFunc(p, x) - y
830 sigmaCutPtcOutliers = self.config.sigmaCutPtcOutliers
831 maxIterationsPtcOutliers = self.config.maxIterationsPtcOutliers
833 for i, ampName in enumerate(dataset.ampNames):
834 timeVecOriginal = np.ravel(np.array(dataset.rawExpTimes[ampName]))
835 meanVecOriginal = np.ravel(np.array(dataset.rawMeans[ampName]))
836 varVecOriginal = np.ravel(np.array(dataset.rawVars[ampName]))
837 varVecOriginal = self._makeZeroSafe(varVecOriginal)
839 # Discard points when the variance starts to decrease after two
840 # consecutive signal levels
841 goodPoints = self._getInitialGoodPoints(meanVecOriginal, varVecOriginal,
842 self.config.minVarPivotSearch,
843 self.config.consecutivePointsVarDecreases)
845 # Check if all points are bad from the 'cpExtractPtcTask'
846 initialExpIdMask = np.ravel(np.array(dataset.expIdMask[ampName]))
848 if not (goodPoints.any() and initialExpIdMask.any()):
849 msg = (f"SERIOUS: All points in goodPoints: {goodPoints} or "
850 f"in initialExpIdMask: {initialExpIdMask} are bad."
851 f"Setting {ampName} to BAD.")
852 self.log.warning(msg)
853 # Fill entries with NaNs
854 self.fillBadAmp(dataset, ptcFitType, ampName)
855 continue
857 # Save the point where the variance starts decreasing as the
858 # PTC turnoff point
859 ptcTurnoff = meanVecOriginal[goodPoints][-1]
860 dataset.ptcTurnoff[ampName] = ptcTurnoff
862 mask = goodPoints
864 if ptcFitType == 'EXPAPPROXIMATION':
865 ptcFunc = funcAstier
866 parsIniPtc = [-1e-9, 1.0, 10.] # a00, gain, noise^2
867 # lowers and uppers obtained from BOT data studies by
868 # C. Lage (UC Davis, 11/2020).
869 bounds = self._boundsForAstier(parsIniPtc, lowers=[-1e-4, 0.5, -2000],
870 uppers=[1e-4, 2.5, 2000])
871 if ptcFitType == 'POLYNOMIAL':
872 ptcFunc = funcPolynomial
873 parsIniPtc = self._initialParsForPolynomial(self.config.polynomialFitDegree + 1)
874 bounds = self._boundsForPolynomial(parsIniPtc)
876 # Before bootstrap fit, do an iterative fit to get rid of outliers.
877 # This further process of outlier rejection be skipped
878 # if self.config.maxIterationsPtcOutliers = 0.
879 # We already did some initial outlier rejection above in
880 # self._getInitialGoodPoints.
881 count = 1
882 newMask = np.ones_like(meanVecOriginal, dtype=bool)
883 pars = parsIniPtc
884 while count <= maxIterationsPtcOutliers:
885 # Note that application of the mask actually shrinks the array
886 # to size rather than setting elements to zero (as we want) so
887 # always update mask itself and re-apply to the original data
888 meanTempVec = meanVecOriginal[mask]
889 varTempVec = varVecOriginal[mask]
890 res = least_squares(errFunc, parsIniPtc, bounds=bounds, args=(meanTempVec, varTempVec))
891 pars = res.x
893 # change this to the original from the temp because
894 # the masks are ANDed meaning once a point is masked
895 # it's always masked, and the masks must always be the
896 # same length for broadcasting
897 sigResids = (varVecOriginal - ptcFunc(pars, meanVecOriginal))/np.sqrt(varVecOriginal)
898 newMask = np.array([True if np.abs(r) < sigmaCutPtcOutliers else False for r in sigResids])
899 mask = mask & newMask
900 if not (mask.any() and newMask.any()):
901 msg = (f"SERIOUS: All points in either mask: {mask} or newMask: {newMask} are bad. "
902 f"Setting {ampName} to BAD.")
903 self.log.warning(msg)
904 # Fill entries with NaNs
905 self.fillBadAmp(dataset, ptcFitType, ampName)
906 break
907 nDroppedTotal = Counter(mask)[False]
908 self.log.debug("Iteration %d: discarded %d points in total for %s",
909 count, nDroppedTotal, ampName)
910 count += 1
911 # objects should never shrink
912 assert (len(mask) == len(timeVecOriginal) == len(meanVecOriginal) == len(varVecOriginal))
913 if not (mask.any() and newMask.any()):
914 continue
915 dataset.expIdMask[ampName] = np.array(dataset.expIdMask[ampName])
916 # store the final mask
917 if len(dataset.expIdMask[ampName]):
918 dataset.expIdMask[ampName] &= mask # bitwise_and if there is already a mask
919 else:
920 dataset.expIdMask[ampName] = mask
921 # In case there was a previous mask stored
922 mask = dataset.expIdMask[ampName]
923 parsIniPtc = pars
924 meanVecFinal = meanVecOriginal[mask]
925 varVecFinal = varVecOriginal[mask]
927 if Counter(mask)[False] > 0:
928 self.log.info("Number of points discarded in PTC of amplifier %s:"
929 " %d out of %d", ampName, Counter(mask)[False], len(meanVecOriginal))
931 if (len(meanVecFinal) < len(parsIniPtc)):
932 msg = (f"SERIOUS: Not enough data points ({len(meanVecFinal)}) compared to the number of "
933 f"parameters of the PTC model({len(parsIniPtc)}). Setting {ampName} to BAD.")
934 self.log.warning(msg)
935 # Fill entries with NaNs
936 self.fillBadAmp(dataset, ptcFitType, ampName)
937 continue
938 # Fit the PTC.
939 # The variance of the variance is Var(v)=2*v^2/Npix. This is
940 # already calculated in `makeCovArray` of CpPtcExtract.
941 # dataset.covariancesSqrtWeights[ampName][:,0,0]
942 # has 1/sqrt(Var(v)).
943 weightsY = dataset.covariancesSqrtWeights[ampName][:, 0, 0][mask]
944 if self.config.doFitBootstrap:
945 parsFit, parsFitErr, reducedChiSqPtc = fitBootstrap(parsIniPtc, meanVecFinal,
946 varVecFinal, ptcFunc,
947 weightsY=weightsY)
948 else:
949 parsFit, parsFitErr, reducedChiSqPtc = fitLeastSq(parsIniPtc, meanVecFinal,
950 varVecFinal, ptcFunc,
951 weightsY=weightsY)
952 dataset.ptcFitPars[ampName] = parsFit
953 dataset.ptcFitParsError[ampName] = parsFitErr
954 dataset.ptcFitChiSq[ampName] = reducedChiSqPtc
955 # Masked variances (measured and modeled) and means. Need
956 # to pad the array so astropy.Table does not crash (the
957 # mask may vary per amp).
958 padLength = len(dataset.rawExpTimes[ampName]) - len(varVecFinal)
959 dataset.finalVars[ampName] = np.pad(varVecFinal, (0, padLength), 'constant',
960 constant_values=np.nan)
961 dataset.finalModelVars[ampName] = np.pad(ptcFunc(parsFit, meanVecFinal), (0, padLength),
962 'constant', constant_values=np.nan)
963 dataset.finalMeans[ampName] = np.pad(meanVecFinal, (0, padLength), 'constant',
964 constant_values=np.nan)
965 if ptcFitType == 'EXPAPPROXIMATION':
966 ptcGain = parsFit[1]
967 ptcGainErr = parsFitErr[1]
968 ptcNoise = np.sqrt(np.fabs(parsFit[2]))
969 ptcNoiseErr = 0.5*(parsFitErr[2]/np.fabs(parsFit[2]))*np.sqrt(np.fabs(parsFit[2]))
970 if ptcFitType == 'POLYNOMIAL':
971 ptcGain = 1./parsFit[1]
972 ptcGainErr = np.fabs(1./parsFit[1])*(parsFitErr[1]/parsFit[1])
973 ptcNoise = np.sqrt(np.fabs(parsFit[0]))*ptcGain
974 ptcNoiseErr = (0.5*(parsFitErr[0]/np.fabs(parsFit[0]))*(np.sqrt(np.fabs(parsFit[0]))))*ptcGain
975 dataset.gain[ampName] = ptcGain
976 dataset.gainErr[ampName] = ptcGainErr
977 dataset.noise[ampName] = ptcNoise
978 dataset.noiseErr[ampName] = ptcNoiseErr
980 if not len(dataset.ptcFitType) == 0:
981 dataset.ptcFitType = ptcFitType
982 if len(dataset.badAmps) == 0:
983 dataset.badAmps = np.repeat(np.nan, len(list(dataset.rawExpTimes.values())[0]))
985 return dataset
987 def fillBadAmp(self, dataset, ptcFitType, ampName):
988 """Fill the dataset with NaNs if there are not enough
989 good points.
991 Parameters
992 ----------
993 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
994 The dataset containing the means, variances and
995 exposure times.
996 ptcFitType : {'POLYNOMIAL', 'EXPAPPROXIMATION'}
997 Fit a 'POLYNOMIAL' (degree: 'polynomialFitDegree') or
998 'EXPAPPROXIMATION' (Eq. 16 of Astier+19) to the PTC.
999 ampName : `str`
1000 Amplifier name.
1001 """
1002 dataset.badAmps.append(ampName)
1003 dataset.expIdMask[ampName] = np.repeat(False, len(dataset.rawExpTimes[ampName]))
1004 dataset.gain[ampName] = np.nan
1005 dataset.gainErr[ampName] = np.nan
1006 dataset.noise[ampName] = np.nan
1007 dataset.noiseErr[ampName] = np.nan
1008 dataset.ptcFitPars[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1) if
1009 ptcFitType in ["POLYNOMIAL", ] else np.repeat(np.nan, 3))
1010 dataset.ptcFitParsError[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1) if
1011 ptcFitType in ["POLYNOMIAL", ] else np.repeat(np.nan, 3))
1012 dataset.ptcFitChiSq[ampName] = np.nan
1013 dataset.ptcTurnoff[ampName] = np.nan
1014 dataset.finalVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1015 dataset.finalModelVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1016 dataset.finalMeans[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1018 return