Coverage for tests/test_linearity.py: 14%
188 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-03 03:48 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-03 03:48 -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 linearity code."""
27import unittest
28import numpy as np
30import lsst.utils
31import lsst.utils.tests
33from lsst.ip.isr import PhotonTransferCurveDataset
35import lsst.afw.image
36import lsst.afw.math
37from lsst.cp.pipe import LinearitySolveTask
38from lsst.cp.pipe.ptc import PhotonTransferCurveSolveTask
39from lsst.cp.pipe.utils import funcPolynomial
40from lsst.ip.isr.isrMock import FlatMock, IsrMock
43class FakeCamera(list):
44 def getName(self):
45 return "FakeCam"
48class LinearityTaskTestCase(lsst.utils.tests.TestCase):
49 """Test case for the linearity tasks."""
51 def setUp(self):
52 mock_image_config = IsrMock.ConfigClass()
53 mock_image_config.flatDrop = 0.99999
54 mock_image_config.isTrimmed = True
56 self.dummy_exposure = FlatMock(config=mock_image_config).run()
57 self.detector = self.dummy_exposure.getDetector()
58 self.input_dims = {"detector": 0}
60 self.camera = FakeCamera([self.detector])
62 self.amp_names = []
63 for amp in self.detector:
64 self.amp_names.append(amp.getName())
66 def _create_ptc(self, amp_names, exp_times, means, ccobcurr=None, photo_charges=None):
67 """
68 Create a PTC with values for linearity tests.
70 Parameters
71 ----------
72 amp_names : `list` [`str`]
73 Names of amps.
74 exp_times : `np.ndarray`
75 Array of exposure times.
76 means : `np.ndarray`
77 Array of means.
78 ccobcurr : `np.ndarray`, optional
79 Array of CCOBCURR to put into auxiliary values.
80 photo_charges : `np.ndarray`, optional
81 Array of photoCharges to put into ptc.
83 Returns
84 -------
85 ptc : `lsst.ip.isr.PhotonTransferCurveDataset`
86 PTC filled with relevant values.
87 """
88 exp_id_pairs = np.arange(len(exp_times)*2).reshape((len(exp_times), 2)).tolist()
90 if photo_charges is None:
91 photo_charges = np.full(len(exp_times), np.nan)
93 datasets = []
94 for i in range(len(exp_times)):
95 partial = PhotonTransferCurveDataset(amp_names, ptcFitType="PARTIAL", covMatrixSide=1)
96 for amp_name in amp_names:
97 # For the first amp, we add a few bad points.
98 if amp_name == amp_names[0] and i >= 5 and i < 7:
99 exp_id_mask = False
100 raw_mean = np.nan
101 else:
102 exp_id_mask = True
103 raw_mean = means[i]
105 partial.setAmpValuesPartialDataset(
106 amp_name,
107 inputExpIdPair=exp_id_pairs[i],
108 rawExpTime=exp_times[i],
109 rawMean=raw_mean,
110 rawVar=raw_mean,
111 kspValue=1.0,
112 expIdMask=exp_id_mask,
113 photoCharge=photo_charges[i],
114 )
116 if ccobcurr is not None:
117 partial.setAuxValuesPartialDataset({"CCOBCURR": ccobcurr[i]})
119 datasets.append(partial)
121 datasets.append(PhotonTransferCurveDataset(amp_names, ptcFitType="DUMMY"))
123 config = PhotonTransferCurveSolveTask.ConfigClass()
124 config.maximumRangeCovariancesAstier = 1
125 config.maxDeltaInitialPtcOutlierFit = 100_000.0
126 solve_task = PhotonTransferCurveSolveTask(config=config)
127 ptc = solve_task.run(datasets).outputPtcDataset
129 # Make the last amp a bad amp.
130 ptc.badAmps = [amp_names[-1]]
132 return ptc
134 def _check_linearity(self, linearity_type, min_adu=0.0, max_adu=100000.0):
135 """Run and check linearity.
137 Parameters
138 ----------
139 linearity_type : `str`
140 Must be ``Polynomial``, ``Squared``, or ``LookupTable``.
141 min_adu : `float`, optional
142 Minimum cut on ADU for fit.
143 max_adu : `float`, optional
144 Maximum cut on ADU for fit.
145 """
146 flux = 1000.
147 time_vec = np.arange(1., 101., 5)
148 k2_non_linearity = -5e-6
149 coeff = k2_non_linearity/(flux**2.)
151 mu_vec = flux * time_vec + k2_non_linearity * time_vec**2.
153 ptc = self._create_ptc(self.amp_names, time_vec, mu_vec)
155 config = LinearitySolveTask.ConfigClass()
156 config.linearityType = linearity_type
157 config.minLinearAdu = min_adu
158 config.maxLinearAdu = max_adu
160 task = LinearitySolveTask(config=config)
161 linearizer = task.run(ptc, [self.dummy_exposure], self.camera, self.input_dims).outputLinearizer
163 if linearity_type == "LookupTable":
164 t_max = config.maxLookupTableAdu / flux
165 time_range = np.linspace(0.0, t_max, config.maxLookupTableAdu)
166 signal_ideal = time_range * flux
167 signal_uncorrected = funcPolynomial(np.array([0.0, flux, k2_non_linearity]), time_range)
168 linearizer_table_row = signal_ideal - signal_uncorrected
170 # Skip the last amp which is marked bad.
171 for i, amp_name in enumerate(ptc.ampNames[:-1]):
172 if linearity_type in ["Squared", "Polynomial"]:
173 self.assertFloatsAlmostEqual(linearizer.fitParams[amp_name][0], 0.0, atol=1e-2)
174 self.assertFloatsAlmostEqual(linearizer.fitParams[amp_name][1], 1.0, rtol=1e-5)
175 self.assertFloatsAlmostEqual(linearizer.fitParams[amp_name][2], coeff, rtol=1e-6)
177 if linearity_type == "Polynomial":
178 self.assertFloatsAlmostEqual(linearizer.fitParams[amp_name][3], 0.0)
180 if linearity_type == "Squared":
181 self.assertEqual(len(linearizer.linearityCoeffs[amp_name]), 1)
182 self.assertFloatsAlmostEqual(linearizer.linearityCoeffs[amp_name][0], -coeff, rtol=1e-6)
183 else:
184 self.assertEqual(len(linearizer.linearityCoeffs[amp_name]), 2)
185 self.assertFloatsAlmostEqual(linearizer.linearityCoeffs[amp_name][0], -coeff, rtol=1e-6)
186 self.assertFloatsAlmostEqual(linearizer.linearityCoeffs[amp_name][1], 0.0)
188 else:
189 index = linearizer.linearityCoeffs[amp_name][0]
190 self.assertEqual(index, i)
191 self.assertEqual(len(linearizer.tableData[index, :]), len(linearizer_table_row))
192 self.assertFloatsAlmostEqual(linearizer.tableData[index, :], linearizer_table_row, rtol=1e-4)
194 lin_mask = np.isfinite(linearizer.fitResiduals[amp_name])
195 lin_mask_expected = (mu_vec > min_adu) & (mu_vec < max_adu) & ptc.expIdMask[amp_name]
197 self.assertListEqual(lin_mask.tolist(), lin_mask_expected.tolist())
198 self.assertFloatsAlmostEqual(linearizer.fitResiduals[amp_name][lin_mask], 0.0, atol=1e-2)
200 # If we apply the linearity correction, we should get the true
201 # linear values out.
202 image = lsst.afw.image.ImageF(len(mu_vec), 1)
203 image.array[:, :] = mu_vec
204 lin_func = linearizer.getLinearityTypeByName(linearizer.linearityType[amp_name])
205 lin_func()(
206 image,
207 coeffs=linearizer.linearityCoeffs[amp_name],
208 table=linearizer.tableData,
209 log=None,
210 )
212 linear_signal = flux * time_vec
213 self.assertFloatsAlmostEqual(image.array[0, :] / linear_signal, 1.0, rtol=1e-6)
215 def test_linearity_polynomial(self):
216 """Test linearity with polynomial fit."""
217 self._check_linearity("Polynomial")
219 def test_linearity_squared(self):
220 """Test linearity with a single order squared solution."""
221 self._check_linearity("Squared")
223 def test_linearity_table(self):
224 """Test linearity with a lookup table solution."""
225 self._check_linearity("LookupTable")
227 def test_linearity_polynomial_aducuts(self):
228 """Test linearity with polynomial and ADU cuts."""
229 self._check_linearity("Polynomial", min_adu=10000.0, max_adu=90000.0)
231 def _check_linearity_spline(self, do_pd_offsets=False, n_points=200):
232 """Check linearity with a spline solution.
234 Parameters
235 ----------
236 do_pd_offsets : `bool`, optional
237 Apply offsets to the photodiode data.
238 """
239 np.random.seed(12345)
241 # Create a test dataset representative of real data.
242 pd_values = np.linspace(1e-8, 2e-5, n_points)
243 time_values = pd_values * 1000000.
244 linear_ratio = 5e9
245 mu_linear = linear_ratio * pd_values
247 # Test spline parameters are taken from a test fit to LSSTCam
248 # data, run 7193D, detector 22, amp C00. The exact fit is not
249 # important, but this is only meant to be representative of
250 # the shape of the non-linearity that we see.
252 n_nodes = 10
254 non_lin_spline_nodes = np.linspace(0, mu_linear.max(), n_nodes)
255 non_lin_spline_values = np.array(
256 [0.0, -8.87, 1.46, 1.69, -6.92, -68.23, -78.01, -11.56, 80.26, 185.01]
257 )
259 spl = lsst.afw.math.makeInterpolate(
260 non_lin_spline_nodes,
261 non_lin_spline_values,
262 lsst.afw.math.stringToInterpStyle("AKIMA_SPLINE"),
263 )
265 mu_values = mu_linear + spl.interpolate(mu_linear)
266 mu_values += np.random.normal(scale=mu_values, size=len(mu_values)) / 10000.
268 # Add some outlier values.
269 if n_points >= 200:
270 outlier_indices = np.arange(5) + 170
271 else:
272 outlier_indices = []
273 mu_values[outlier_indices] += 200.0
275 # Add some small offsets to the pd_values if requested.
276 pd_values_offset = pd_values.copy()
277 ccobcurr = None
278 if do_pd_offsets:
279 ccobcurr = np.zeros(pd_values.size)
280 n_points_group = n_points//4
281 group0 = np.arange(n_points_group)
282 group1 = np.arange(n_points_group) + n_points_group
283 group2 = np.arange(n_points_group) + 2*n_points_group
284 group3 = np.arange(n_points_group) + 3*n_points_group
285 ccobcurr[group0] = 0.01
286 ccobcurr[group1] = 0.02
287 ccobcurr[group2] = 0.03
288 ccobcurr[group3] = 0.04
290 pd_offset_factors = [0.995, 1.0, 1.005, 1.002]
291 pd_values_offset[group0] *= pd_offset_factors[0]
292 pd_values_offset[group2] *= pd_offset_factors[2]
293 pd_values_offset[group3] *= pd_offset_factors[3]
295 # Add one bad photodiode value, but don't put it at the very
296 # end because that would change the spline node positions
297 # and make comparisons to the "truth" here in the tests
298 # more difficult.
299 pd_values_offset[-2] = np.nan
301 ptc = self._create_ptc(
302 self.amp_names,
303 time_values,
304 mu_values,
305 ccobcurr=ccobcurr,
306 photo_charges=pd_values_offset,
307 )
309 config = LinearitySolveTask.ConfigClass()
310 config.linearityType = "Spline"
311 config.usePhotodiode = True
312 config.minLinearAdu = 0.0
313 config.maxLinearAdu = np.nanmax(mu_values) + 1.0
314 config.splineKnots = n_nodes
315 config.splineGroupingMinPoints = 101
317 if do_pd_offsets:
318 config.splineGroupingColumn = "CCOBCURR"
320 task = LinearitySolveTask(config=config)
321 linearizer = task.run(
322 ptc,
323 [self.dummy_exposure],
324 self.camera,
325 self.input_dims,
326 ).outputLinearizer
328 # Skip the last amp which is marked bad.
329 for amp_name in ptc.ampNames[:-1]:
330 lin_mask = np.isfinite(linearizer.fitResiduals[amp_name])
332 # Make sure that anything in the input mask is still masked.
333 check, = np.where(~ptc.expIdMask[amp_name])
334 if len(check) > 0:
335 self.assertEqual(np.all(lin_mask[check]), False)
337 # Make sure the outliers are masked.
338 self.assertEqual(np.all(lin_mask[outlier_indices]), False)
340 # The first point at very low flux is noisier and so we exclude
341 # it from the test here.
342 self.assertFloatsAlmostEqual(
343 (linearizer.fitResiduals[amp_name][lin_mask] / mu_linear[lin_mask])[1:],
344 0.0,
345 atol=1.1e-3,
346 )
348 # If we apply the linearity correction, we should get the true
349 # linear values out.
350 image = lsst.afw.image.ImageF(len(mu_values), 1)
351 image.array[:, :] = mu_values
352 lin_func = linearizer.getLinearityTypeByName(linearizer.linearityType[amp_name])
353 lin_func()(
354 image,
355 coeffs=linearizer.linearityCoeffs[amp_name],
356 log=None,
357 )
359 # We scale by the median because of ambiguity in the overall
360 # gain parameter which is not part of the non-linearity.
361 ratio = image.array[0, lin_mask]/mu_linear[lin_mask]
362 self.assertFloatsAlmostEqual(
363 ratio / np.median(ratio),
364 1.0,
365 rtol=5e-4,
366 )
368 # Check that the spline parameters recovered are consistent,
369 # with input to some low-grade precision.
370 # The first element should be identically zero.
371 self.assertFloatsEqual(linearizer.linearityCoeffs[amp_name][0], 0.0)
373 # We have two different comparisons here; for the terms that are
374 # |value| < 20 (offset) or |value| > 20 (ratio), to avoid
375 # divide-by-small-number problems. In all cases these are
376 # approximate, and the real test is in the residuals.
377 small = (np.abs(non_lin_spline_values) < 20)
379 spline_atol = 6.0 if do_pd_offsets else 2.0
380 spline_rtol = 0.14 if do_pd_offsets else 0.05
382 self.assertFloatsAlmostEqual(
383 linearizer.linearityCoeffs[amp_name][n_nodes:][small],
384 non_lin_spline_values[small],
385 atol=spline_atol,
386 )
387 self.assertFloatsAlmostEqual(
388 linearizer.linearityCoeffs[amp_name][n_nodes:][~small],
389 non_lin_spline_values[~small],
390 rtol=spline_rtol,
391 )
393 # And check the offsets if they were included.
394 if do_pd_offsets:
395 # The relative scaling is to group 1.
396 fit_offset_factors = linearizer.fitParams[amp_name][1] / linearizer.fitParams[amp_name]
398 self.assertFloatsAlmostEqual(fit_offset_factors, np.array(pd_offset_factors), rtol=6e-4)
400 def test_linearity_spline(self):
401 self._check_linearity_spline()
403 def test_linearity_spline_offsets(self):
404 self._check_linearity_spline(do_pd_offsets=True)
406 def test_linearity_spline_offsets_too_few_points(self):
407 with self.assertRaisesRegex(RuntimeError, "too few points"):
408 self._check_linearity_spline(do_pd_offsets=True, n_points=100)
411class TestMemory(lsst.utils.tests.MemoryTestCase):
412 pass
415def setup_module(module):
416 lsst.utils.tests.init()
419if __name__ == "__main__": 419 ↛ 420line 419 didn't jump to line 420, because the condition on line 419 was never true
420 lsst.utils.tests.init()
421 unittest.main()