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

1#!/usr/bin/env python 

2 

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.""" 

26 

27import unittest 

28import numpy as np 

29 

30import lsst.utils 

31import lsst.utils.tests 

32 

33from lsst.ip.isr import PhotonTransferCurveDataset 

34 

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 

41 

42 

43class FakeCamera(list): 

44 def getName(self): 

45 return "FakeCam" 

46 

47 

48class LinearityTaskTestCase(lsst.utils.tests.TestCase): 

49 """Test case for the linearity tasks.""" 

50 

51 def setUp(self): 

52 mock_image_config = IsrMock.ConfigClass() 

53 mock_image_config.flatDrop = 0.99999 

54 mock_image_config.isTrimmed = True 

55 

56 self.dummy_exposure = FlatMock(config=mock_image_config).run() 

57 self.detector = self.dummy_exposure.getDetector() 

58 self.input_dims = {"detector": 0} 

59 

60 self.camera = FakeCamera([self.detector]) 

61 

62 self.amp_names = [] 

63 for amp in self.detector: 

64 self.amp_names.append(amp.getName()) 

65 

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. 

69 

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. 

82 

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() 

89 

90 if photo_charges is None: 

91 photo_charges = np.full(len(exp_times), np.nan) 

92 

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] 

104 

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 ) 

115 

116 if ccobcurr is not None: 

117 partial.setAuxValuesPartialDataset({"CCOBCURR": ccobcurr[i]}) 

118 

119 datasets.append(partial) 

120 

121 datasets.append(PhotonTransferCurveDataset(amp_names, ptcFitType="DUMMY")) 

122 

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 

128 

129 # Make the last amp a bad amp. 

130 ptc.badAmps = [amp_names[-1]] 

131 

132 return ptc 

133 

134 def _check_linearity(self, linearity_type, min_adu=0.0, max_adu=100000.0): 

135 """Run and check linearity. 

136 

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.) 

150 

151 mu_vec = flux * time_vec + k2_non_linearity * time_vec**2. 

152 

153 ptc = self._create_ptc(self.amp_names, time_vec, mu_vec) 

154 

155 config = LinearitySolveTask.ConfigClass() 

156 config.linearityType = linearity_type 

157 config.minLinearAdu = min_adu 

158 config.maxLinearAdu = max_adu 

159 

160 task = LinearitySolveTask(config=config) 

161 linearizer = task.run(ptc, [self.dummy_exposure], self.camera, self.input_dims).outputLinearizer 

162 

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 

169 

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) 

176 

177 if linearity_type == "Polynomial": 

178 self.assertFloatsAlmostEqual(linearizer.fitParams[amp_name][3], 0.0) 

179 

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) 

187 

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) 

193 

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] 

196 

197 self.assertListEqual(lin_mask.tolist(), lin_mask_expected.tolist()) 

198 self.assertFloatsAlmostEqual(linearizer.fitResiduals[amp_name][lin_mask], 0.0, atol=1e-2) 

199 

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 ) 

211 

212 linear_signal = flux * time_vec 

213 self.assertFloatsAlmostEqual(image.array[0, :] / linear_signal, 1.0, rtol=1e-6) 

214 

215 def test_linearity_polynomial(self): 

216 """Test linearity with polynomial fit.""" 

217 self._check_linearity("Polynomial") 

218 

219 def test_linearity_squared(self): 

220 """Test linearity with a single order squared solution.""" 

221 self._check_linearity("Squared") 

222 

223 def test_linearity_table(self): 

224 """Test linearity with a lookup table solution.""" 

225 self._check_linearity("LookupTable") 

226 

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) 

230 

231 def _check_linearity_spline(self, do_pd_offsets=False, n_points=200): 

232 """Check linearity with a spline solution. 

233 

234 Parameters 

235 ---------- 

236 do_pd_offsets : `bool`, optional 

237 Apply offsets to the photodiode data. 

238 """ 

239 np.random.seed(12345) 

240 

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 

246 

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. 

251 

252 n_nodes = 10 

253 

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 ) 

258 

259 spl = lsst.afw.math.makeInterpolate( 

260 non_lin_spline_nodes, 

261 non_lin_spline_values, 

262 lsst.afw.math.stringToInterpStyle("AKIMA_SPLINE"), 

263 ) 

264 

265 mu_values = mu_linear + spl.interpolate(mu_linear) 

266 mu_values += np.random.normal(scale=mu_values, size=len(mu_values)) / 10000. 

267 

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 

274 

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 

289 

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] 

294 

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 

300 

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 ) 

308 

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 

316 

317 if do_pd_offsets: 

318 config.splineGroupingColumn = "CCOBCURR" 

319 

320 task = LinearitySolveTask(config=config) 

321 linearizer = task.run( 

322 ptc, 

323 [self.dummy_exposure], 

324 self.camera, 

325 self.input_dims, 

326 ).outputLinearizer 

327 

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]) 

331 

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) 

336 

337 # Make sure the outliers are masked. 

338 self.assertEqual(np.all(lin_mask[outlier_indices]), False) 

339 

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 ) 

347 

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 ) 

358 

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 ) 

367 

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) 

372 

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) 

378 

379 spline_atol = 6.0 if do_pd_offsets else 2.0 

380 spline_rtol = 0.14 if do_pd_offsets else 0.05 

381 

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 ) 

392 

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] 

397 

398 self.assertFloatsAlmostEqual(fit_offset_factors, np.array(pd_offset_factors), rtol=6e-4) 

399 

400 def test_linearity_spline(self): 

401 self._check_linearity_spline() 

402 

403 def test_linearity_spline_offsets(self): 

404 self._check_linearity_spline(do_pd_offsets=True) 

405 

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) 

409 

410 

411class TestMemory(lsst.utils.tests.MemoryTestCase): 

412 pass 

413 

414 

415def setup_module(module): 

416 lsst.utils.tests.init() 

417 

418 

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()