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