Coverage for tests/test_ptc.py: 9%
297 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-11 02:59 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-11 02:59 -0700
1#!/usr/bin/env python
3#
4# LSST Data Management System
5#
6# Copyright 2008-2017 AURA/LSST.
7#
8# This product includes software developed by the
9# LSST Project (http://www.lsst.org/).
10#
11# This program is free software: you can redistribute it and/or modify
12# it under the terms of the GNU General Public License as published by
13# the Free Software Foundation, either version 3 of the License, or
14# (at your option) any later version.
15#
16# This program is distributed in the hope that it will be useful,
17# but WITHOUT ANY WARRANTY; without even the implied warranty of
18# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19# GNU General Public License for more details.
20#
21# You should have received a copy of the LSST License Statement and
22# the GNU General Public License along with this program. If not,
23# see <https://www.lsstcorp.org/LegalNotices/>.
24#
25"""Test cases for cp_pipe."""
27import unittest
28import numpy as np
29import copy
31import lsst.utils
32import lsst.utils.tests
34import lsst.cp.pipe as cpPipe
35import lsst.ip.isr.isrMock as isrMock
36from lsst.ip.isr import PhotonTransferCurveDataset
37from lsst.cp.pipe.utils import (funcPolynomial, makeMockFlats)
39from lsst.pipe.base import TaskMetadata
42class FakeCamera(list):
43 def getName(self):
44 return "FakeCam"
47class PretendRef():
48 "A class to act as a mock exposure reference"
49 def __init__(self, exposure):
50 self.exp = exposure
52 def get(self, component=None):
53 if component == 'visitInfo':
54 return self.exp.getVisitInfo()
55 elif component == 'detector':
56 return self.exp.getDetector()
57 elif component == 'metadata':
58 return self.exp.getMetadata()
59 else:
60 return self.exp
63class MeasurePhotonTransferCurveTaskTestCase(lsst.utils.tests.TestCase):
64 """A test case for the PTC tasks."""
66 def setUp(self):
67 self.defaultConfigExtract = cpPipe.ptc.PhotonTransferCurveExtractTask.ConfigClass()
68 self.defaultTaskExtract = cpPipe.ptc.PhotonTransferCurveExtractTask(config=self.defaultConfigExtract)
70 self.defaultConfigSolve = cpPipe.ptc.PhotonTransferCurveSolveTask.ConfigClass()
71 self.defaultTaskSolve = cpPipe.ptc.PhotonTransferCurveSolveTask(config=self.defaultConfigSolve)
73 self.flatMean = 2000
74 self.readNoiseAdu = 10
75 mockImageConfig = isrMock.IsrMock.ConfigClass()
77 # flatDrop is not really relevant as we replace the data
78 # but good to note it in case we change how this image is made
79 mockImageConfig.flatDrop = 0.99999
80 mockImageConfig.isTrimmed = True
82 self.flatExp1 = isrMock.FlatMock(config=mockImageConfig).run()
83 self.flatExp2 = self.flatExp1.clone()
84 (shapeY, shapeX) = self.flatExp1.getDimensions()
86 self.flatWidth = np.sqrt(self.flatMean) + self.readNoiseAdu
88 self.rng1 = np.random.RandomState(1984)
89 flatData1 = self.rng1.normal(self.flatMean, self.flatWidth, (shapeX, shapeY))
90 self.rng2 = np.random.RandomState(666)
91 flatData2 = self.rng2.normal(self.flatMean, self.flatWidth, (shapeX, shapeY))
93 self.flatExp1.image.array[:] = flatData1
94 self.flatExp2.image.array[:] = flatData2
96 # create fake PTC data to see if fit works, for one amp ('amp')
97 self.flux = 1000. # ADU/sec
98 self.timeVec = np.arange(1., 101., 5)
99 self.k2NonLinearity = -5e-6
100 # quadratic signal-chain non-linearity
101 muVec = self.flux*self.timeVec + self.k2NonLinearity*self.timeVec**2
102 self.gain = 0.75 # e-/ADU
103 self.c1 = 1./self.gain
104 self.noiseSq = 2*self.gain # 7.5 (e-)^2
105 self.a00 = -1.2e-6
106 self.c2 = -1.5e-6
107 self.c3 = -4.7e-12 # tuned so that it turns over for 200k mean
109 self.ampNames = [amp.getName() for amp in self.flatExp1.getDetector().getAmplifiers()]
110 self.dataset = PhotonTransferCurveDataset(self.ampNames, ptcFitType="PARTIAL")
111 self.covariancesSqrtWeights = {}
112 for ampName in self.ampNames: # just the expTimes and means here - vars vary per function
113 self.dataset.rawExpTimes[ampName] = self.timeVec
114 self.dataset.rawMeans[ampName] = muVec
115 self.dataset.covariancesSqrtWeights[ampName] = np.zeros((1,
116 self.dataset.covMatrixSide,
117 self.dataset.covMatrixSide))
119 # ISR metadata
120 self.metadataContents = TaskMetadata()
121 self.metadataContents["isr"] = {}
122 # Overscan readout noise [in ADU]
123 for amp in self.ampNames:
124 self.metadataContents["isr"][f"RESIDUAL STDEV {amp}"] = np.sqrt(self.noiseSq)/self.gain
126 def test_covAstier(self):
127 """Test to check getCovariancesAstier
129 We check that the gain is the same as the imput gain from the
130 mock data, that the covariances via FFT (as it is in
131 MeasurePhotonTransferCurveTask when doCovariancesAstier=True)
132 are the same as calculated in real space, and that Cov[0, 0]
133 (i.e., the variances) are similar to the variances calculated
134 with the standard method (when doCovariancesAstier=false),
136 """
137 extractConfig = self.defaultConfigExtract
138 extractConfig.minNumberGoodPixelsForCovariance = 5000
139 extractConfig.detectorMeasurementRegion = 'FULL'
140 extractTask = cpPipe.ptc.PhotonTransferCurveExtractTask(config=extractConfig)
142 solveConfig = self.defaultConfigSolve
143 solveConfig.ptcFitType = 'FULLCOVARIANCE'
144 solveTask = cpPipe.ptc.PhotonTransferCurveSolveTask(config=solveConfig)
146 inputGain = self.gain
148 muStandard, varStandard = {}, {}
149 expDict = {}
150 expIds = []
151 idCounter = 0
152 for expTime in self.timeVec:
153 mockExp1, mockExp2 = makeMockFlats(expTime, gain=inputGain,
154 readNoiseElectrons=3,
155 expId1=idCounter, expId2=idCounter+1)
156 mockExpRef1 = PretendRef(mockExp1)
157 mockExpRef2 = PretendRef(mockExp2)
158 expDict[expTime] = ((mockExpRef1, idCounter), (mockExpRef2, idCounter+1))
159 expIds.append(idCounter)
160 expIds.append(idCounter+1)
161 for ampNumber, ampName in enumerate(self.ampNames):
162 # cov has (i, j, var, cov, npix)
163 im1Area, im2Area, imStatsCtrl, mu1, mu2 = extractTask.getImageAreasMasksStats(mockExp1,
164 mockExp2)
165 muDiff, varDiff, covAstier = extractTask.measureMeanVarCov(im1Area, im2Area, imStatsCtrl,
166 mu1, mu2)
167 muStandard.setdefault(ampName, []).append(muDiff)
168 varStandard.setdefault(ampName, []).append(varDiff)
169 idCounter += 2
171 resultsExtract = extractTask.run(inputExp=expDict, inputDims=expIds,
172 taskMetadata=[self.metadataContents for x in expIds])
174 # Force the last PTC dataset to have a NaN, and ensure that the
175 # task runs (DM-38029). This is a minor perturbation and does not
176 # affect the output comparison.
177 resultsExtract.outputCovariances[-2].rawMeans['C:0,0'] = np.array([np.nan])
178 resultsExtract.outputCovariances[-2].rawVars['C:0,0'] = np.array([np.nan])
180 resultsSolve = solveTask.run(resultsExtract.outputCovariances,
181 camera=FakeCamera([self.flatExp1.getDetector()]))
183 for amp in self.ampNames:
184 self.assertAlmostEqual(resultsSolve.outputPtcDataset.gain[amp], inputGain, places=2)
185 for v1, v2 in zip(varStandard[amp], resultsSolve.outputPtcDataset.finalVars[amp]):
186 self.assertAlmostEqual(v1/v2, 1.0, places=1)
188 # Test various operations on the PTC output from the task.
189 ptc = resultsSolve.outputPtcDataset
191 expIdsUsed = ptc.getExpIdsUsed("C:0,0")
192 # Check that these are the same as the inputs, paired up, with the
193 # final two removed.
194 self.assertTrue(np.all(expIdsUsed == np.array(expIds).reshape(len(expIds) // 2, 2)[:-1]))
196 goodAmps = ptc.getGoodAmps()
197 self.assertEqual(goodAmps, self.ampNames)
199 def ptcFitAndCheckPtc(self, order=None, fitType=None, doTableArray=False, doFitBootstrap=False):
200 localDataset = copy.deepcopy(self.dataset)
201 localDataset.ptcFitType = fitType
202 configSolve = copy.copy(self.defaultConfigSolve)
203 configLin = cpPipe.linearity.LinearitySolveTask.ConfigClass()
204 placesTests = 6
205 if doFitBootstrap:
206 configSolve.doFitBootstrap = True
207 # Bootstrap method in cp_pipe/utils.py does multiple fits
208 # in the precense of noise. Allow for more margin of
209 # error.
210 placesTests = 3
212 if fitType == 'POLYNOMIAL':
213 if order not in [2, 3]:
214 RuntimeError("Enter a valid polynomial order for this test: 2 or 3")
215 if order == 2:
216 for ampName in self.ampNames:
217 localDataset.rawVars[ampName] = [self.noiseSq + self.c1*mu + self.c2*mu**2 for
218 mu in localDataset.rawMeans[ampName]]
219 configSolve.polynomialFitDegree = 2
220 if order == 3:
221 for ampName in self.ampNames:
222 localDataset.rawVars[ampName] = [self.noiseSq + self.c1*mu + self.c2*mu**2 + self.c3*mu**3
223 for mu in localDataset.rawMeans[ampName]]
224 configSolve.polynomialFitDegree = 3
225 elif fitType == 'EXPAPPROXIMATION':
226 g = self.gain
227 for ampName in self.ampNames:
228 localDataset.rawVars[ampName] = [(0.5/(self.a00*g**2)*(np.exp(2*self.a00*mu*g)-1)
229 + self.noiseSq/(g*g))
230 for mu in localDataset.rawMeans[ampName]]
231 else:
232 raise RuntimeError("Enter a fit function type: 'POLYNOMIAL' or 'EXPAPPROXIMATION'")
234 # Initialize mask and covariance weights that will be used in fits.
235 # Covariance weights values empirically determined from one of
236 # the cases in test_covAstier.
237 matrixSize = localDataset.covMatrixSide
238 maskLength = len(localDataset.rawMeans[ampName])
239 for ampName in self.ampNames:
240 localDataset.expIdMask[ampName] = np.repeat(True, maskLength)
241 localDataset.covariancesSqrtWeights[ampName] = np.repeat(np.ones((matrixSize, matrixSize)),
242 maskLength).reshape((maskLength,
243 matrixSize,
244 matrixSize))
245 localDataset.covariancesSqrtWeights[ampName][:, 0, 0] = [0.07980188, 0.01339653, 0.0073118,
246 0.00502802, 0.00383132, 0.00309475,
247 0.00259572, 0.00223528, 0.00196273,
248 0.00174943, 0.00157794, 0.00143707,
249 0.00131929, 0.00121935, 0.0011334,
250 0.00105893, 0.00099357, 0.0009358,
251 0.00088439, 0.00083833]
253 configLin.maxLookupTableAdu = 200000 # Max ADU in input mock flats
254 configLin.maxLinearAdu = 100000
255 configLin.minLinearAdu = 50000
256 if doTableArray:
257 configLin.linearityType = "LookupTable"
258 else:
259 configLin.linearityType = "Polynomial"
260 solveTask = cpPipe.ptc.PhotonTransferCurveSolveTask(config=configSolve)
261 linearityTask = cpPipe.linearity.LinearitySolveTask(config=configLin)
263 if doTableArray:
264 # Non-linearity
265 numberAmps = len(self.ampNames)
266 # localDataset: PTC dataset
267 # (`lsst.ip.isr.ptcDataset.PhotonTransferCurveDataset`)
268 localDataset = solveTask.fitMeasurementsToModel(localDataset)
269 # linDataset here is a lsst.pipe.base.Struct
270 linDataset = linearityTask.run(localDataset,
271 dummy=[1.0],
272 camera=FakeCamera([self.flatExp1.getDetector()]),
273 inputPhotodiodeData={},
274 inputDims={'detector': 0})
275 linDataset = linDataset.outputLinearizer
276 else:
277 localDataset = solveTask.fitMeasurementsToModel(localDataset)
278 linDataset = linearityTask.run(localDataset,
279 dummy=[1.0],
280 camera=FakeCamera([self.flatExp1.getDetector()]),
281 inputPhotodiodeData={},
282 inputDims={'detector': 0})
283 linDataset = linDataset.outputLinearizer
284 if doTableArray:
285 # check that the linearizer table has been filled out properly
286 for i in np.arange(numberAmps):
287 tMax = (configLin.maxLookupTableAdu)/self.flux
288 timeRange = np.linspace(0., tMax, configLin.maxLookupTableAdu)
289 signalIdeal = timeRange*self.flux
290 signalUncorrected = funcPolynomial(np.array([0.0, self.flux, self.k2NonLinearity]),
291 timeRange)
292 linearizerTableRow = signalIdeal - signalUncorrected
293 self.assertEqual(len(linearizerTableRow), len(linDataset.tableData[i, :]))
294 for j in np.arange(len(linearizerTableRow)):
295 self.assertAlmostEqual(linearizerTableRow[j], linDataset.tableData[i, :][j],
296 places=placesTests)
297 else:
298 # check entries in localDataset, which was modified by the function
299 for ampName in self.ampNames:
300 maskAmp = localDataset.expIdMask[ampName]
301 finalMuVec = localDataset.rawMeans[ampName][maskAmp]
302 finalTimeVec = localDataset.rawExpTimes[ampName][maskAmp]
303 linearPart = self.flux*finalTimeVec
304 inputFracNonLinearityResiduals = 100*(linearPart - finalMuVec)/linearPart
305 self.assertEqual(fitType, localDataset.ptcFitType)
306 self.assertAlmostEqual(self.gain, localDataset.gain[ampName])
307 if fitType == 'POLYNOMIAL':
308 self.assertAlmostEqual(self.c1, localDataset.ptcFitPars[ampName][1])
309 self.assertAlmostEqual(np.sqrt(self.noiseSq)*self.gain, localDataset.noise[ampName])
310 if fitType == 'EXPAPPROXIMATION':
311 self.assertAlmostEqual(self.a00, localDataset.ptcFitPars[ampName][0])
312 # noise already in electrons for 'EXPAPPROXIMATION' fit
313 self.assertAlmostEqual(np.sqrt(self.noiseSq), localDataset.noise[ampName])
315 # check entries in returned dataset (a dict of , for nonlinearity)
316 for ampName in self.ampNames:
317 maskAmp = localDataset.expIdMask[ampName]
318 finalMuVec = localDataset.rawMeans[ampName][maskAmp]
319 finalTimeVec = localDataset.rawExpTimes[ampName][maskAmp]
320 linearPart = self.flux*finalTimeVec
321 inputFracNonLinearityResiduals = 100*(linearPart - finalMuVec)/linearPart
323 # Nonlinearity fit parameters
324 # Polynomial fits are now normalized to unit flux scaling
325 self.assertAlmostEqual(0.0, linDataset.fitParams[ampName][0], places=1)
326 self.assertAlmostEqual(1.0, linDataset.fitParams[ampName][1],
327 places=5)
329 # Non-linearity coefficient for linearizer
330 squaredCoeff = self.k2NonLinearity/(self.flux**2)
331 self.assertAlmostEqual(squaredCoeff, linDataset.fitParams[ampName][2],
332 places=placesTests)
333 self.assertAlmostEqual(-squaredCoeff, linDataset.linearityCoeffs[ampName][2],
334 places=placesTests)
336 linearPartModel = linDataset.fitParams[ampName][1]*finalTimeVec*self.flux
337 outputFracNonLinearityResiduals = 100*(linearPartModel - finalMuVec)/linearPartModel
338 # Fractional nonlinearity residuals
339 self.assertEqual(len(outputFracNonLinearityResiduals), len(inputFracNonLinearityResiduals))
340 for calc, truth in zip(outputFracNonLinearityResiduals, inputFracNonLinearityResiduals):
341 self.assertAlmostEqual(calc, truth, places=3)
343 def test_ptcFit(self):
344 for createArray in [True, False]:
345 for (fitType, order) in [('POLYNOMIAL', 2), ('POLYNOMIAL', 3), ('EXPAPPROXIMATION', None)]:
346 self.ptcFitAndCheckPtc(fitType=fitType, order=order, doTableArray=createArray)
348 def test_meanVarMeasurement(self):
349 task = self.defaultTaskExtract
350 im1Area, im2Area, imStatsCtrl, mu1, mu2 = task.getImageAreasMasksStats(self.flatExp1,
351 self.flatExp2)
352 mu, varDiff, _ = task.measureMeanVarCov(im1Area, im2Area, imStatsCtrl, mu1, mu2)
354 self.assertLess(self.flatWidth - np.sqrt(varDiff), 1)
355 self.assertLess(self.flatMean - mu, 1)
357 def test_meanVarMeasurementWithNans(self):
358 task = self.defaultTaskExtract
359 self.flatExp1.image.array[20:30, :] = np.nan
360 self.flatExp2.image.array[20:30, :] = np.nan
362 im1Area, im2Area, imStatsCtrl, mu1, mu2 = task.getImageAreasMasksStats(self.flatExp1,
363 self.flatExp2)
364 mu, varDiff, _ = task.measureMeanVarCov(im1Area, im2Area, imStatsCtrl, mu1, mu2)
366 expectedMu1 = np.nanmean(self.flatExp1.image.array)
367 expectedMu2 = np.nanmean(self.flatExp2.image.array)
368 expectedMu = 0.5*(expectedMu1 + expectedMu2)
370 # Now the variance of the difference. First, create the diff image.
371 im1 = self.flatExp1.maskedImage
372 im2 = self.flatExp2.maskedImage
374 temp = im2.clone()
375 temp *= expectedMu1
376 diffIm = im1.clone()
377 diffIm *= expectedMu2
378 diffIm -= temp
379 diffIm /= expectedMu
381 # Divide by two as it is what measureMeanVarCov returns
382 # (variance of difference)
383 expectedVar = 0.5*np.nanvar(diffIm.image.array)
385 # Check that the standard deviations and the emans agree to
386 # less than 1 ADU
387 self.assertLess(np.sqrt(expectedVar) - np.sqrt(varDiff), 1)
388 self.assertLess(expectedMu - mu, 1)
390 def test_meanVarMeasurementAllNan(self):
391 task = self.defaultTaskExtract
392 self.flatExp1.image.array[:, :] = np.nan
393 self.flatExp2.image.array[:, :] = np.nan
395 im1Area, im2Area, imStatsCtrl, mu1, mu2 = task.getImageAreasMasksStats(self.flatExp1,
396 self.flatExp2)
397 mu, varDiff, covDiff = task.measureMeanVarCov(im1Area, im2Area, imStatsCtrl, mu1, mu2)
399 self.assertTrue(np.isnan(mu))
400 self.assertTrue(np.isnan(varDiff))
401 self.assertTrue(covDiff is None)
403 def test_makeZeroSafe(self):
404 noZerosArray = [1., 20, -35, 45578.98, 90.0, 897, 659.8]
405 someZerosArray = [1., 20, 0, 0, 90, 879, 0]
406 allZerosArray = [0., 0.0, 0, 0, 0.0, 0, 0]
408 substituteValue = 1e-10
410 expectedSomeZerosArray = [1., 20, substituteValue, substituteValue, 90, 879, substituteValue]
411 expectedAllZerosArray = np.repeat(substituteValue, len(allZerosArray))
413 measuredSomeZerosArray = self.defaultTaskSolve._makeZeroSafe(someZerosArray,
414 substituteValue=substituteValue)
415 measuredAllZerosArray = self.defaultTaskSolve._makeZeroSafe(allZerosArray,
416 substituteValue=substituteValue)
417 measuredNoZerosArray = self.defaultTaskSolve._makeZeroSafe(noZerosArray,
418 substituteValue=substituteValue)
420 for exp, meas in zip(expectedSomeZerosArray, measuredSomeZerosArray):
421 self.assertEqual(exp, meas)
422 for exp, meas in zip(expectedAllZerosArray, measuredAllZerosArray):
423 self.assertEqual(exp, meas)
424 for exp, meas in zip(noZerosArray, measuredNoZerosArray):
425 self.assertEqual(exp, meas)
427 def test_getInitialGoodPoints(self):
428 xs = [1, 2, 3, 4, 5, 6]
429 ys = [2*x for x in xs]
430 points = self.defaultTaskSolve._getInitialGoodPoints(xs, ys, minVarPivotSearch=0.,
431 consecutivePointsVarDecreases=2)
432 assert np.all(points) == np.all(np.array([True for x in xs]))
434 ys[4] = 7 # Variance decreases in two consecutive points after ys[3]=8
435 ys[5] = 6
436 points = self.defaultTaskSolve._getInitialGoodPoints(xs, ys, minVarPivotSearch=0.,
437 consecutivePointsVarDecreases=2)
438 assert np.all(points) == np.all(np.array([True, True, True, True, False]))
440 def runGetGainFromFlatPair(self, correctionType='NONE'):
441 extractConfig = self.defaultConfigExtract
442 extractConfig.gainCorrectionType = correctionType
443 extractConfig.minNumberGoodPixelsForCovariance = 5000
444 extractTask = cpPipe.ptc.PhotonTransferCurveExtractTask(config=extractConfig)
446 expDict = {}
447 expIds = []
448 idCounter = 0
449 inputGain = self.gain # 1.5 e/ADU
450 for expTime in self.timeVec:
451 # Approximation works better at low flux, e.g., < 10000 ADU
452 mockExp1, mockExp2 = makeMockFlats(expTime, gain=inputGain,
453 readNoiseElectrons=np.sqrt(self.noiseSq),
454 fluxElectrons=100,
455 expId1=idCounter, expId2=idCounter+1)
456 mockExpRef1 = PretendRef(mockExp1)
457 mockExpRef2 = PretendRef(mockExp2)
458 expDict[expTime] = ((mockExpRef1, idCounter), (mockExpRef2, idCounter+1))
459 expIds.append(idCounter)
460 expIds.append(idCounter+1)
461 idCounter += 2
463 resultsExtract = extractTask.run(inputExp=expDict, inputDims=expIds,
464 taskMetadata=[self.metadataContents for x in expIds])
465 for exposurePair in resultsExtract.outputCovariances:
466 for ampName in self.ampNames:
467 if exposurePair.gain[ampName] is np.nan:
468 continue
469 self.assertAlmostEqual(exposurePair.gain[ampName], inputGain, delta=0.04)
471 def test_getGainFromFlatPair(self):
472 for gainCorrectionType in ['NONE', 'SIMPLE', 'FULL', ]:
473 self.runGetGainFromFlatPair(gainCorrectionType)
476class MeasurePhotonTransferCurveDatasetTestCase(lsst.utils.tests.TestCase):
477 def setUp(self):
478 self.ptcData = PhotonTransferCurveDataset(['C00', 'C01'], " ")
479 self.ptcData.inputExpIdPairs = {'C00': [(123, 234), (345, 456), (567, 678)],
480 'C01': [(123, 234), (345, 456), (567, 678)]}
482 def test_generalBehaviour(self):
483 test = PhotonTransferCurveDataset(['C00', 'C01'], " ")
484 test.inputExpIdPairs = {'C00': [(123, 234), (345, 456), (567, 678)],
485 'C01': [(123, 234), (345, 456), (567, 678)]}
488class TestMemory(lsst.utils.tests.MemoryTestCase):
489 pass
492def setup_module(module):
493 lsst.utils.tests.init()
496if __name__ == "__main__": 496 ↛ 497line 496 didn't jump to line 497, because the condition on line 496 was never true
497 lsst.utils.tests.init()
498 unittest.main()