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