Coverage for python/lsst/cp/pipe/ptc/cpSolvePtcTask.py: 11%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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 pipeBase.CmdLineTask):
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 outputs = self.run(inputCovariances=inputs['inputCovariances'], camera=inputs['camera'])
191 butlerQC.put(outputs, outputRefs)
193 def run(self, inputCovariances, camera=None, inputExpList=None):
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.
201 camera : `lsst.afw.cameraGeom.Camera`, optional
202 Input camera.
204 inputExpList : `list` [`~lsst.afw.image.ExposureF`], optional
205 List of exposures.
207 Returns
208 -------
209 results : `lsst.pipe.base.Struct`
210 The results struct containing:
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, self.config.ptcFitType,
220 self.config.maximumRangeCovariancesAstier)
221 for partialPtcDataset in inputCovariances:
222 # Ignore dummy datasets
223 if partialPtcDataset.ptcFitType == 'DUMMY':
224 continue
225 for ampName in ampNames:
226 datasetPtc.inputExpIdPairs[ampName].append(partialPtcDataset.inputExpIdPairs[ampName])
227 if type(partialPtcDataset.rawExpTimes[ampName]) is list:
228 datasetPtc.rawExpTimes[ampName].append(partialPtcDataset.rawExpTimes[ampName][0])
229 else:
230 datasetPtc.rawExpTimes[ampName].append(partialPtcDataset.rawExpTimes[ampName])
231 if type(partialPtcDataset.rawMeans[ampName]) is list:
232 datasetPtc.rawMeans[ampName].append(partialPtcDataset.rawMeans[ampName][0])
233 else:
234 datasetPtc.rawMeans[ampName].append(partialPtcDataset.rawMeans[ampName])
235 if type(partialPtcDataset.rawVars[ampName]) is list:
236 datasetPtc.rawVars[ampName].append(partialPtcDataset.rawVars[ampName][0])
237 else:
238 datasetPtc.rawVars[ampName].append(partialPtcDataset.rawVars[ampName])
239 if type(partialPtcDataset.expIdMask[ampName]) is list:
240 datasetPtc.expIdMask[ampName].append(partialPtcDataset.expIdMask[ampName][0])
241 else:
242 datasetPtc.expIdMask[ampName].append(partialPtcDataset.expIdMask[ampName])
243 datasetPtc.covariances[ampName].append(np.array(partialPtcDataset.covariances[ampName][0]))
244 datasetPtc.covariancesSqrtWeights[ampName].append(
245 np.array(partialPtcDataset.covariancesSqrtWeights[ampName][0]))
246 # Sort arrays that are filled so far in the final dataset by
247 # rawMeans index
248 for ampName in ampNames:
249 index = np.argsort(np.ravel(np.array(datasetPtc.rawMeans[ampName])))
250 datasetPtc.inputExpIdPairs[ampName] = np.array(datasetPtc.inputExpIdPairs[ampName])[index]
251 datasetPtc.rawExpTimes[ampName] = np.array(datasetPtc.rawExpTimes[ampName])[index]
252 datasetPtc.rawMeans[ampName] = np.array(datasetPtc.rawMeans[ampName])[index]
253 datasetPtc.rawVars[ampName] = np.array(datasetPtc.rawVars[ampName])[index]
254 datasetPtc.expIdMask[ampName] = np.array(datasetPtc.expIdMask[ampName])[index]
255 datasetPtc.covariances[ampName] = np.array(datasetPtc.covariances[ampName])[index]
256 datasetPtc.covariancesSqrtWeights[ampName] = np.array(
257 datasetPtc.covariancesSqrtWeights[ampName])[index]
258 if self.config.ptcFitType == "FULLCOVARIANCE":
259 # Fit the measured covariances vs mean signal to
260 # the Astier+19 full model (Eq. 20). Before that
261 # do a preliminary fit to the variance (C_00) vs mean
262 # signal (mu) curve using the EXPAPPROXIMATION model
263 # (Eq. 16 in Astier+19) in order to
264 # get the flat pairs that are masked. The
265 # points at these fluxes will also be masked when
266 # calculating the other elements of the covariance
267 # matrix, C_ij, i!=j).
269 # Preliminary fit, usign a temp dataset to get the mask
270 tempDatasetPtc = copy.copy(datasetPtc)
271 tempDatasetPtc.ptcFitType = "EXPAPPROXIMATION"
272 tempDatasetPtc = self.fitMeasurementsToModel(tempDatasetPtc)
274 # "FULLCOVARIANCE", using the mask obtained from the
275 # previous fit.
276 for ampName in datasetPtc.ampNames:
277 datasetPtc.expIdMask[ampName] = tempDatasetPtc.expIdMask[ampName]
278 datasetPtc.fitType = "FULLCOVARIANCE"
279 datasetPtc = self.fitMeasurementsToModel(datasetPtc)
280 # The other options are: self.config.ptcFitType in
281 # ("EXPAPPROXIMATION", "POLYNOMIAL")
282 else:
283 # Fit the PTC to a polynomial or to Astier+19 exponential
284 # approximation (Eq. 16). Fill up
285 # PhotonTransferCurveDataset object.
286 datasetPtc = self.fitMeasurementsToModel(datasetPtc)
287 if inputExpList is not None:
288 # It should be a list of exposures, to get the detector.
289 detector = inputExpList[0].getDetector()
290 else:
291 detector = None
292 datasetPtc.updateMetadata(setDate=True, camera=camera, detector=detector)
294 return pipeBase.Struct(
295 outputPtcDataset=datasetPtc,
296 )
298 def fitMeasurementsToModel(self, dataset):
299 """Fit the measured covariances vs mean signal to a
300 polynomial or one of the models in Astier+19
301 (Eq. 16 or Eq.20).
303 Parameters
304 ----------
305 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
306 The dataset containing information such as the means,
307 (co)variances, and exposure times.
309 Returns
310 -------
311 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
312 This is the same dataset as the input parameter, however,
313 it has been modified to include information such as the
314 fit vectors and the fit parameters. See the class
315 `PhotonTransferCurveDatase`.
316 """
317 fitType = dataset.ptcFitType
318 if fitType in ["FULLCOVARIANCE", ]:
319 # This model uses the full covariance matrix in the fit.
320 # The PTC is technically defined as variance vs signal,
321 # with variance = Cov_00
322 dataset = self.fitDataFullCovariance(dataset)
323 elif fitType in ["POLYNOMIAL", "EXPAPPROXIMATION"]:
324 # The PTC is technically defined as variance vs signal
325 dataset = self.fitPtc(dataset)
326 else:
327 raise RuntimeError(
328 f"Fitting option {fitType} not one of "
329 "'POLYNOMIAL', 'EXPAPPROXIMATION', or 'FULLCOVARIANCE'"
330 )
332 return dataset
334 def fitDataFullCovariance(self, dataset):
335 """Fit measured flat covariances to the full model in
336 Astier+19 (Eq. 20).
338 Parameters
339 ----------
340 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
341 The dataset containing information such as the means,
342 (co)variances, and exposure times.
344 Returns
345 -------
346 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
347 This is the same dataset as the input parameter, however,
348 it has been modified to include information such as the
349 fit vectors and the fit parameters. See the class
350 `PhotonTransferCurveDatase`.
352 Notes
353 -----
354 The parameters of the full model for C_ij(mu) ("C_ij" and "mu"
355 in ADU^2 and ADU, respectively) in Astier+19 (Eq. 20) are:
356 "a" coefficients (r by r matrix), units: 1/e
357 "b" coefficients (r by r matrix), units: 1/e
358 noise matrix (r by r matrix), units: e^2
359 gain, units: e/ADU
360 "b" appears in Eq. 20 only through the "ab" combination, which
361 is defined in this code as "c=ab".
363 Total number of parameters: #entries(a) + #entries(c) + #entries(noise)
364 + 1. This is equivalent to r^2 + r^2 + r^2 + 1, where "r" is the
365 maximum lag considered for the covariances calculation, and the
366 extra "1" is the gain. If "b" is 0, then "c" is 0, and len(pInit) will
367 have r^2 fewer entries.
368 """
369 matrixSide = self.config.maximumRangeCovariancesAstier
370 lenParams = matrixSide*matrixSide
372 for ampName in dataset.ampNames:
373 lenInputTimes = len(dataset.rawExpTimes[ampName])
374 # Not used when ptcFitType is 'FULLCOVARIANCE'
375 dataset.ptcFitPars[ampName] = [np.nan]
376 dataset.ptcFitParsError[ampName] = [np.nan]
377 dataset.ptcFitChiSq[ampName] = np.nan
379 if ampName in dataset.badAmps:
380 # Bad amp
381 # Entries need to have proper dimensions so read/write
382 # with astropy.Table works.
383 nanMatrix = np.full((matrixSide, matrixSide), np.nan)
384 listNanMatrix = np.full((lenInputTimes, matrixSide, matrixSide), np.nan)
385 dataset.covariancesModel[ampName] = listNanMatrix
386 dataset.covariancesSqrtWeights[ampName] = listNanMatrix
387 dataset.aMatrix[ampName] = nanMatrix
388 dataset.bMatrix[ampName] = nanMatrix
389 dataset.covariancesModelNoB[ampName] = listNanMatrix
390 dataset.aMatrixNoB[ampName] = nanMatrix
392 dataset.expIdMask[ampName] = np.repeat(np.nan, lenInputTimes)
393 dataset.gain[ampName] = np.nan
394 dataset.gainErr[ampName] = np.nan
395 dataset.noise[ampName] = np.nan
396 dataset.noiseErr[ampName] = np.nan
397 dataset.finalVars[ampName] = np.repeat(np.nan, lenInputTimes)
398 dataset.finalModelVars[ampName] = np.repeat(np.nan, lenInputTimes)
399 dataset.finalMeans[ampName] = np.repeat(np.nan, lenInputTimes)
400 continue
402 muAtAmp = dataset.rawMeans[ampName]
403 maskAtAmp = dataset.expIdMask[ampName]
404 if len(maskAtAmp) == 0:
405 maskAtAmp = np.repeat(True, len(muAtAmp))
407 muAtAmp = muAtAmp[maskAtAmp]
408 covAtAmp = np.nan_to_num(dataset.covariances[ampName])[maskAtAmp]
409 covSqrtWeightsAtAmp = np.nan_to_num(dataset.covariancesSqrtWeights[ampName])[maskAtAmp]
411 # Initial fit, to approximate parameters, with c=0
412 a0, c0, noise0, gain0 = self.initialFitFullCovariance(muAtAmp, covAtAmp, covSqrtWeightsAtAmp)
414 # Fit full model (Eq. 20 of Astier+19) and same model with
415 # b=0 (c=0 in this code)
416 pInit = np.concatenate((a0.flatten(), c0.flatten(), noise0.flatten(), np.array(gain0)), axis=None)
417 functionsDict = {'fullModel': self.funcFullCovarianceModel,
418 'fullModelNoB': self.funcFullCovarianceModelNoB}
419 fitResults = {'fullModel': {'a': [], 'c': [], 'noise': [], 'gain': [], 'paramsErr': []},
420 'fullModelNoB': {'a': [], 'c': [], 'noise': [], 'gain': [], 'paramsErr': []}}
421 for key in functionsDict:
422 params, paramsErr, _ = fitLeastSq(pInit, muAtAmp,
423 covAtAmp.flatten(), functionsDict[key],
424 weightsY=covSqrtWeightsAtAmp.flatten())
425 a = params[:lenParams].reshape((matrixSide, matrixSide))
426 c = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide))
427 noise = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide))
428 gain = params[-1]
430 fitResults[key]['a'] = a
431 fitResults[key]['c'] = c
432 fitResults[key]['noise'] = noise
433 fitResults[key]['gain'] = gain
434 fitResults[key]['paramsErr'] = paramsErr
436 # Put the information in the PTC dataset
438 # Not used when ptcFitType is 'FULLCOVARIANCE'
439 dataset.ptcFitPars[ampName] = [np.nan]
440 dataset.ptcFitParsError[ampName] = [np.nan]
441 dataset.ptcFitChiSq[ampName] = np.nan
443 # Save full covariances, covariances models, and their weights
444 # dataset.expIdMask is already full
445 dataset.covariances[ampName] = covAtAmp
446 dataset.covariancesModel[ampName] = self.evalCovModel(muAtAmp,
447 fitResults['fullModel']['a'],
448 fitResults['fullModel']['c'],
449 fitResults['fullModel']['noise'],
450 fitResults['fullModel']['gain'])
451 dataset.covariancesSqrtWeights[ampName] = covSqrtWeightsAtAmp
452 dataset.aMatrix[ampName] = fitResults['fullModel']['a']
453 dataset.bMatrix[ampName] = fitResults['fullModel']['c']/fitResults['fullModel']['a']
454 dataset.covariancesModelNoB[ampName] = self.evalCovModel(muAtAmp,
455 fitResults['fullModelNoB']['a'],
456 fitResults['fullModelNoB']['c'],
457 fitResults['fullModelNoB']['noise'],
458 fitResults['fullModelNoB']['gain'],
459 setBtoZero=True)
460 dataset.aMatrixNoB[ampName] = fitResults['fullModelNoB']['a']
461 dataset.gain[ampName] = fitResults['fullModel']['gain']
462 dataset.gainErr[ampName] = fitResults['fullModel']['paramsErr'][-1]
463 readoutNoise = fitResults['fullModel']['noise'][0][0]
464 readoutNoiseSqrt = np.sqrt(np.fabs(readoutNoise))
465 dataset.noise[ampName] = readoutNoise
466 readoutNoiseSigma = fitResults['fullModel']['paramsErr'][2*lenParams]
467 dataset.noiseErr[ampName] = 0.5*(readoutNoiseSigma/np.fabs(readoutNoise))*readoutNoiseSqrt
468 dataset.finalVars[ampName] = covAtAmp[:, 0, 0]
469 dataset.finalModelVars[ampName] = dataset.covariancesModel[ampName][:, 0, 0]
470 dataset.finalMeans[ampName] = muAtAmp
472 return dataset
474 def initialFitFullCovariance(self, mu, cov, sqrtW):
475 """ Performs a crude parabolic fit of the data in order to start
476 the full fit close to the solution, setting b=0 (c=0) in Eq. 20
477 of Astier+19.
479 Parameters
480 ----------
481 mu : `numpy.array`, (N,)
482 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`.
489 sqrtW : `numpy.array`, (N,)
490 Covariance weights, defined as 1./sqrt(Variances)
492 Returns
493 -------
494 a : `numpy.array`, (M, M)
495 "a" parameter per flux in Eq. 20 of Astier+19.
497 c : `numpy.array`, (M, M)
498 "c"="ab" parameter per flux in Eq. 20 of Astier+19.
500 noise : `numpy.array`, (M, M)
501 "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).
549 x : `numpy.array`, (N,)
550 Signal `mu` (ADU)
552 Returns
553 -------
554 y : `numpy.array`, (N,)
555 Covariance matrix.
556 """
557 matrixSide = self.config.maximumRangeCovariancesAstier
558 lenParams = matrixSide*matrixSide
559 aMatrix = params[:lenParams].reshape((matrixSide, matrixSide))
560 cMatrix = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide))
561 noiseMatrix = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide))
562 gain = params[-1]
564 return self.evalCovModel(x, aMatrix, cMatrix, noiseMatrix, gain).flatten()
566 def funcFullCovarianceModelNoB(self, params, x):
567 """Model to fit covariances from flat fields; Equation 20 of
568 Astier+19, with b=0 (equivalent to c=a*b=0 in this code).
570 Parameters
571 ----------
572 params : `list`
573 Parameters of the model: aMatrix, CMatrix, noiseMatrix,
574 gain (e/ADU).
576 x : `numpy.array`, (N,)
577 Signal mu (ADU)
579 Returns
580 -------
581 y : `numpy.array`, (N,)
582 Covariance matrix.
583 """
584 matrixSide = self.config.maximumRangeCovariancesAstier
585 lenParams = matrixSide*matrixSide
586 aMatrix = params[:lenParams].reshape((matrixSide, matrixSide))
587 cMatrix = params[lenParams:2*lenParams].reshape((matrixSide, matrixSide))
588 noiseMatrix = params[2*lenParams:3*lenParams].reshape((matrixSide, matrixSide))
589 gain = params[-1]
591 return self.evalCovModel(x, aMatrix, cMatrix, noiseMatrix, gain, setBtoZero=True).flatten()
593 def evalCovModel(self, mu, aMatrix, cMatrix, noiseMatrix, gain, setBtoZero=False):
594 """Computes full covariances model (Eq. 20 of Astier+19).
596 Parameters
597 ----------
598 mu : `numpy.array`, (N,)
599 List of mean signals.
601 aMatrix : `numpy.array`, (M, M)
602 "a" parameter per flux in Eq. 20 of Astier+19.
604 cMatrix : `numpy.array`, (M, M)
605 "c"="ab" parameter per flux in Eq. 20 of Astier+19.
607 noiseMatrix : `numpy.array`, (M, M)
608 "noise" parameter per flux in Eq. 20 of Astier+19.
610 gain : `float`
611 Amplifier gain (e/ADU)
613 setBtoZero=False : `bool`, optional
614 Set "b" parameter in full model (see Astier+19) to zero.
616 Returns
617 -------
618 covModel : `numpy.array`, (N, M, M)
619 Covariances model.
621 Notes
622 -----
623 By default, computes the covModel for the mu's stored(self.mu).
624 Returns cov[Nmu, M, M]. The variance for the PTC is
625 cov[:, 0, 0]. mu and cov are in ADUs and ADUs squared. To use
626 electrons for both, the gain should be set to 1. This routine
627 implements the model in Astier+19 (1905.08677).
628 The parameters of the full model for C_ij(mu) ("C_ij" and "mu"
629 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
634 "b" appears in Eq. 20 only through the "ab" combination, which
635 is defined in this code as "c=ab".
636 """
637 matrixSide = self.config.maximumRangeCovariancesAstier
638 sa = (matrixSide, matrixSide)
639 # pad a with zeros and symmetrize
640 aEnlarged = np.zeros((int(sa[0]*1.5)+1, int(sa[1]*1.5)+1))
641 aEnlarged[0:sa[0], 0:sa[1]] = aMatrix
642 aSym = symmetrize(aEnlarged)
643 # pad c with zeros and symmetrize
644 cEnlarged = np.zeros((int(sa[0]*1.5)+1, int(sa[1]*1.5)+1))
645 cEnlarged[0:sa[0], 0:sa[1]] = cMatrix
646 cSym = symmetrize(cEnlarged)
647 a2 = fftconvolve(aSym, aSym, mode='same')
648 a3 = fftconvolve(a2, aSym, mode='same')
649 ac = fftconvolve(aSym, cSym, mode='same')
650 (xc, yc) = np.unravel_index(np.abs(aSym).argmax(), a2.shape)
652 a1 = aMatrix[np.newaxis, :, :]
653 a2 = a2[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide]
654 a3 = a3[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide]
655 ac = ac[np.newaxis, xc:xc + matrixSide, yc:yc + matrixSide]
656 c1 = cMatrix[np.newaxis, ::]
658 # assumes that mu is 1d
659 bigMu = mu[:, np.newaxis, np.newaxis]*gain
660 # c(=a*b in Astier+19) also has a contribution to the last
661 # term, that is absent for now.
662 if setBtoZero:
663 c1 = np.zeros_like(c1)
664 ac = np.zeros_like(ac)
665 covModel = (bigMu/(gain*gain)*(a1*bigMu+2./3.*(bigMu*bigMu)*(a2 + c1)
666 + (1./3.*a3 + 5./6.*ac)*(bigMu*bigMu*bigMu)) + noiseMatrix[np.newaxis, :, :]/gain**2)
667 # add the Poisson term, and the read out noise (variance)
668 covModel[:, 0, 0] += mu/gain
670 return covModel
672 # EXPAPPROXIMATION and POLYNOMIAL fit methods
673 @staticmethod
674 def _initialParsForPolynomial(order):
675 assert(order >= 2)
676 pars = np.zeros(order, dtype=float)
677 pars[0] = 10
678 pars[1] = 1
679 pars[2:] = 0.0001
680 return pars
682 @staticmethod
683 def _boundsForPolynomial(initialPars, lowers=[], uppers=[]):
684 if not len(lowers):
685 lowers = [np.NINF for p in initialPars]
686 if not len(uppers):
687 uppers = [np.inf for p in initialPars]
688 lowers[1] = 0 # no negative gains
689 return (lowers, uppers)
691 @staticmethod
692 def _boundsForAstier(initialPars, lowers=[], uppers=[]):
693 if not len(lowers):
694 lowers = [np.NINF for p in initialPars]
695 if not len(uppers):
696 uppers = [np.inf for p in initialPars]
697 return (lowers, uppers)
699 @staticmethod
700 def _getInitialGoodPoints(means, variances, minVarPivotSearch, consecutivePointsVarDecreases):
701 """Return a boolean array to mask bad points.
703 Parameters
704 ----------
705 means : `numpy.array`
706 Input array with mean signal values.
707 variances : `numpy.array`
708 Input array with variances at each mean value.
709 minVarPivotSearch : `float`
710 The variance (in ADU^2), above which, the point
711 of decreasing variance should be sought.
712 consecutivePointsVarDecreases : `int`
713 Required number of consecutive points/fluxes
714 in the PTC where the variance
715 decreases in order to find a first
716 estimate of the PTC turn-off.
718 Returns
719 ------
720 goodPoints : `numpy.array` [`bool`]
721 Boolean array to select good (`True`) and bad (`False`)
722 points.
724 Notes
725 -----
726 Eliminate points beyond which the variance decreases.
727 """
728 goodPoints = np.ones_like(means, dtype=bool)
729 # Variances are sorted and should monotonically increase
730 pivotList = np.where(np.array(np.diff(variances)) < 0)[0]
731 if len(pivotList) > 0:
732 # For small values, sometimes the variance decreases slightly
733 # Only look when var > self.config.minVarPivotSearch
734 pivotList = [p for p in pivotList if variances[p] > minVarPivotSearch]
735 # Require that the varince decreases during
736 # consecutivePointsVarDecreases
737 # consecutive points. This will give a first
738 # estimate of the PTC turn-off, which
739 # may be updated (reduced) further in the code.
740 if len(pivotList) > 1:
741 # enumerate(pivotList) creates tuples (index, value), for
742 # each value in pivotList. The lambda function subtracts
743 # each value from the index.
744 # groupby groups elements by equal key value.
745 for k, g in groupby(enumerate(pivotList), lambda x: x[0]-x[1]):
746 group = (map(itemgetter(1), g))
747 # Form groups of consecute values from pivotList
748 group = list(map(int, group))
749 # values in pivotList are indices where np.diff(variances)
750 # is negative, i.e., where the variance starts decreasing.
751 # Find the first group of consecutive numbers when
752 # variance decreases.
753 if len(group) >= consecutivePointsVarDecreases:
754 pivotIndex = np.min(group)
755 goodPoints[pivotIndex+1:] = False
756 break
758 return goodPoints
760 def _makeZeroSafe(self, array, substituteValue=1e-9):
761 """"""
762 array = np.array(array)
763 nBad = Counter(np.ravel(array))[0]
764 if nBad == 0:
765 return array
767 index, = np.where(array == 0)
768 if len(index):
769 msg = f"Found {nBad} zeros in array at elements {index}"
770 self.log.warning(msg)
772 array[index] = substituteValue
774 return array
776 def fitPtc(self, dataset):
777 """Fit the photon transfer curve to a polynomial or to the
778 Astier+19 approximation (Eq. 16).
780 Fit the photon transfer curve with either a polynomial of
781 the order specified in the task config, or using the
782 exponential approximation in Astier+19 (Eq. 16).
784 Sigma clipping is performed iteratively for the fit, as
785 well as an initial clipping of data points that are more
786 than `config.initialNonLinearityExclusionThreshold` away
787 from lying on a straight line. This other step is necessary
788 because the photon transfer curve turns over catastrophically
789 at very high flux (because saturation
790 drops the variance to ~0) and these far outliers cause the
791 initial fit to fail, meaning the sigma cannot be calculated
792 to perform the sigma-clipping.
794 Parameters
795 ----------
796 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
797 The dataset containing the means, variances and
798 exposure times.
800 Returns
801 -------
802 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
803 This is the same dataset as the input parameter, however,
804 it has been modified to include information such as the
805 fit vectors and the fit parameters. See the class
806 `PhotonTransferCurveDatase`.
808 Raises
809 ------
810 RuntimeError:
811 Raises if dataset.ptcFitType is None or empty.
812 """
813 if dataset.ptcFitType:
814 ptcFitType = dataset.ptcFitType
815 else:
816 raise RuntimeError("ptcFitType is None of empty in PTC dataset.")
817 matrixSide = self.config.maximumRangeCovariancesAstier
818 nanMatrix = np.empty((matrixSide, matrixSide))
819 nanMatrix[:] = np.nan
821 for amp in dataset.ampNames:
822 lenInputTimes = len(dataset.rawExpTimes[amp])
823 listNanMatrix = np.empty((lenInputTimes, matrixSide, matrixSide))
824 listNanMatrix[:] = np.nan
826 dataset.covariancesModel[amp] = listNanMatrix
827 dataset.aMatrix[amp] = nanMatrix
828 dataset.bMatrix[amp] = nanMatrix
829 dataset.covariancesModelNoB[amp] = listNanMatrix
830 dataset.aMatrixNoB[amp] = nanMatrix
832 def errFunc(p, x, y):
833 return ptcFunc(p, x) - y
835 sigmaCutPtcOutliers = self.config.sigmaCutPtcOutliers
836 maxIterationsPtcOutliers = self.config.maxIterationsPtcOutliers
838 for i, ampName in enumerate(dataset.ampNames):
839 timeVecOriginal = np.ravel(np.array(dataset.rawExpTimes[ampName]))
840 meanVecOriginal = np.ravel(np.array(dataset.rawMeans[ampName]))
841 varVecOriginal = np.ravel(np.array(dataset.rawVars[ampName]))
842 varVecOriginal = self._makeZeroSafe(varVecOriginal)
844 # Discard points when the variance starts to decrease after two
845 # consecutive signal levels
846 goodPoints = self._getInitialGoodPoints(meanVecOriginal, varVecOriginal,
847 self.config.minVarPivotSearch,
848 self.config.consecutivePointsVarDecreases)
849 # Check if all points are bad from the 'cpExtractPtcTask'
850 initialExpIdMask = np.ravel(np.array(dataset.expIdMask[ampName]))
852 if not (goodPoints.any() and initialExpIdMask.any()):
853 msg = (f"SERIOUS: All points in goodPoints: {goodPoints} or "
854 f"in initialExpIdMask: {initialExpIdMask} are bad."
855 f"Setting {ampName} to BAD.")
856 self.log.warning(msg)
857 # Fill entries with NaNs
858 self.fillBadAmp(dataset, ptcFitType, ampName)
859 continue
861 mask = goodPoints
863 if ptcFitType == 'EXPAPPROXIMATION':
864 ptcFunc = funcAstier
865 parsIniPtc = [-1e-9, 1.0, 10.] # a00, gain, noise^2
866 # lowers and uppers obtained from BOT data studies by
867 # C. Lage (UC Davis, 11/2020).
868 bounds = self._boundsForAstier(parsIniPtc, lowers=[-1e-4, 0.5, -2000],
869 uppers=[1e-4, 2.5, 2000])
870 if ptcFitType == 'POLYNOMIAL':
871 ptcFunc = funcPolynomial
872 parsIniPtc = self._initialParsForPolynomial(self.config.polynomialFitDegree + 1)
873 bounds = self._boundsForPolynomial(parsIniPtc)
875 # Before bootstrap fit, do an iterative fit to get rid of outliers.
876 # This further process of outlier rejection be skipped
877 # if self.config.maxIterationsPtcOutliers = 0.
878 # We already did some initial outlier rejection above in
879 # self._getInitialGoodPoints.
880 count = 1
881 newMask = np.ones_like(meanVecOriginal, dtype=bool)
882 pars = parsIniPtc
883 while count <= maxIterationsPtcOutliers:
884 # Note that application of the mask actually shrinks the array
885 # to size rather than setting elements to zero (as we want) so
886 # always update mask itself and re-apply to the original data
887 meanTempVec = meanVecOriginal[mask]
888 varTempVec = varVecOriginal[mask]
889 res = least_squares(errFunc, parsIniPtc, bounds=bounds, args=(meanTempVec, varTempVec))
890 pars = res.x
892 # change this to the original from the temp because
893 # the masks are ANDed meaning once a point is masked
894 # it's always masked, and the masks must always be the
895 # same length for broadcasting
896 sigResids = (varVecOriginal - ptcFunc(pars, meanVecOriginal))/np.sqrt(varVecOriginal)
897 newMask = np.array([True if np.abs(r) < sigmaCutPtcOutliers else False for r in sigResids])
898 mask = mask & newMask
899 if not (mask.any() and newMask.any()):
900 msg = (f"SERIOUS: All points in either mask: {mask} or newMask: {newMask} are bad. "
901 f"Setting {ampName} to BAD.")
902 self.log.warning(msg)
903 # Fill entries with NaNs
904 self.fillBadAmp(dataset, ptcFitType, ampName)
905 break
906 nDroppedTotal = Counter(mask)[False]
907 self.log.debug("Iteration %d: discarded %d points in total for %s",
908 count, nDroppedTotal, ampName)
909 count += 1
910 # objects should never shrink
911 assert (len(mask) == len(timeVecOriginal) == len(meanVecOriginal) == len(varVecOriginal))
912 if not (mask.any() and newMask.any()):
913 continue
914 dataset.expIdMask[ampName] = np.array(dataset.expIdMask[ampName])
915 # store the final mask
916 if len(dataset.expIdMask[ampName]):
917 dataset.expIdMask[ampName] &= mask # bitwise_and if there is already a mask
918 else:
919 dataset.expIdMask[ampName] = mask
920 parsIniPtc = pars
921 meanVecFinal = meanVecOriginal[mask]
922 varVecFinal = varVecOriginal[mask]
924 if Counter(mask)[False] > 0:
925 self.log.info("Number of points discarded in PTC of amplifier %s:"
926 " %d out of %d", ampName, Counter(mask)[False], len(meanVecOriginal))
928 if (len(meanVecFinal) < len(parsIniPtc)):
929 msg = (f"SERIOUS: Not enough data points ({len(meanVecFinal)}) compared to the number of "
930 f"parameters of the PTC model({len(parsIniPtc)}). Setting {ampName} to BAD.")
931 self.log.warning(msg)
932 # Fill entries with NaNs
933 self.fillBadAmp(dataset, ptcFitType, ampName)
934 continue
935 # Fit the PTC
936 if self.config.doFitBootstrap:
937 parsFit, parsFitErr, reducedChiSqPtc = fitBootstrap(parsIniPtc, meanVecFinal,
938 varVecFinal, ptcFunc,
939 weightsY=1./np.sqrt(varVecFinal))
940 else:
941 parsFit, parsFitErr, reducedChiSqPtc = fitLeastSq(parsIniPtc, meanVecFinal,
942 varVecFinal, ptcFunc,
943 weightsY=1./np.sqrt(varVecFinal))
944 dataset.ptcFitPars[ampName] = parsFit
945 dataset.ptcFitParsError[ampName] = parsFitErr
946 dataset.ptcFitChiSq[ampName] = reducedChiSqPtc
947 # Masked variances (measured and modeled) and means. Need
948 # to pad the array so astropy.Table does not crash (the
949 # mask may vary per amp).
950 padLength = len(dataset.rawExpTimes[ampName]) - len(varVecFinal)
951 dataset.finalVars[ampName] = np.pad(varVecFinal, (0, padLength), 'constant',
952 constant_values=np.nan)
953 dataset.finalModelVars[ampName] = np.pad(ptcFunc(parsFit, meanVecFinal), (0, padLength),
954 'constant', constant_values=np.nan)
955 dataset.finalMeans[ampName] = np.pad(meanVecFinal, (0, padLength), 'constant',
956 constant_values=np.nan)
957 if ptcFitType == 'EXPAPPROXIMATION':
958 ptcGain = parsFit[1]
959 ptcGainErr = parsFitErr[1]
960 ptcNoise = np.sqrt(np.fabs(parsFit[2]))
961 ptcNoiseErr = 0.5*(parsFitErr[2]/np.fabs(parsFit[2]))*np.sqrt(np.fabs(parsFit[2]))
962 if ptcFitType == 'POLYNOMIAL':
963 ptcGain = 1./parsFit[1]
964 ptcGainErr = np.fabs(1./parsFit[1])*(parsFitErr[1]/parsFit[1])
965 ptcNoise = np.sqrt(np.fabs(parsFit[0]))*ptcGain
966 ptcNoiseErr = (0.5*(parsFitErr[0]/np.fabs(parsFit[0]))*(np.sqrt(np.fabs(parsFit[0]))))*ptcGain
967 dataset.gain[ampName] = ptcGain
968 dataset.gainErr[ampName] = ptcGainErr
969 dataset.noise[ampName] = ptcNoise
970 dataset.noiseErr[ampName] = ptcNoiseErr
972 if not len(dataset.ptcFitType) == 0:
973 dataset.ptcFitType = ptcFitType
974 if len(dataset.badAmps) == 0:
975 dataset.badAmps = np.repeat(np.nan, len(list(dataset.rawExpTimes.values())[0]))
977 return dataset
979 def fillBadAmp(self, dataset, ptcFitType, ampName):
980 """Fill the dataset with NaNs if there are not enough
981 good points.
983 Parameters
984 ----------
985 dataset : `lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`
986 The dataset containing the means, variances and
987 exposure times.
988 ptcFitType : {'POLYNOMIAL', 'EXPAPPROXIMATION'}
989 Fit a 'POLYNOMIAL' (degree: 'polynomialFitDegree') or
990 'EXPAPPROXIMATION' (Eq. 16 of Astier+19) to the PTC.
991 ampName : `str`
992 Amplifier name.
993 """
994 dataset.badAmps.append(ampName)
995 dataset.expIdMask[ampName] = np.repeat(False, len(dataset.rawExpTimes[ampName]))
996 dataset.gain[ampName] = np.nan
997 dataset.gainErr[ampName] = np.nan
998 dataset.noise[ampName] = np.nan
999 dataset.noiseErr[ampName] = np.nan
1000 dataset.ptcFitPars[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1) if
1001 ptcFitType in ["POLYNOMIAL", ] else np.repeat(np.nan, 3))
1002 dataset.ptcFitParsError[ampName] = (np.repeat(np.nan, self.config.polynomialFitDegree + 1) if
1003 ptcFitType in ["POLYNOMIAL", ] else np.repeat(np.nan, 3))
1004 dataset.ptcFitChiSq[ampName] = np.nan
1005 dataset.finalVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1006 dataset.finalModelVars[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1007 dataset.finalMeans[ampName] = np.repeat(np.nan, len(dataset.rawExpTimes[ampName]))
1009 return