Coverage for tests/test_linearity.py: 11%

260 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-15 02:24 -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, temperatures=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 temperatures : `np.ndarray`, optional 

83 Array of temperatures (TEMP6) to put into ptc. 

84 

85 Returns 

86 ------- 

87 ptc : `lsst.ip.isr.PhotonTransferCurveDataset` 

88 PTC filled with relevant values. 

89 """ 

90 exp_id_pairs = np.arange(len(exp_times)*2).reshape((len(exp_times), 2)).tolist() 

91 

92 if photo_charges is None: 

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

94 

95 datasets = [] 

96 for i in range(len(exp_times)): 

97 partial = PhotonTransferCurveDataset(amp_names, ptcFitType="PARTIAL", covMatrixSide=1) 

98 for amp_name in amp_names: 

99 # For the first amp, we add a few bad points. 

100 if amp_name == amp_names[0] and i >= 5 and i < 7: 

101 exp_id_mask = False 

102 raw_mean = np.nan 

103 else: 

104 exp_id_mask = True 

105 raw_mean = means[i] 

106 

107 partial.setAmpValuesPartialDataset( 

108 amp_name, 

109 inputExpIdPair=exp_id_pairs[i], 

110 rawExpTime=exp_times[i], 

111 rawMean=raw_mean, 

112 rawVar=raw_mean, 

113 kspValue=1.0, 

114 expIdMask=exp_id_mask, 

115 photoCharge=photo_charges[i], 

116 ) 

117 

118 aux_dict = {} 

119 if ccobcurr is not None: 

120 aux_dict["CCOBCURR"] = ccobcurr[i] 

121 if temperatures is not None: 

122 aux_dict["TEMP6"] = temperatures[i] 

123 

124 if aux_dict: 

125 partial.setAuxValuesPartialDataset(aux_dict) 

126 

127 datasets.append(partial) 

128 

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

130 

131 config = PhotonTransferCurveSolveTask.ConfigClass() 

132 config.maximumRangeCovariancesAstier = 1 

133 config.maxDeltaInitialPtcOutlierFit = 100_000.0 

134 solve_task = PhotonTransferCurveSolveTask(config=config) 

135 ptc = solve_task.run(datasets).outputPtcDataset 

136 

137 # Make the last amp a bad amp. 

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

139 

140 return ptc 

141 

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

143 """Run and check linearity. 

144 

145 Parameters 

146 ---------- 

147 linearity_type : `str` 

148 Must be ``Polynomial``, ``Squared``, or ``LookupTable``. 

149 min_adu : `float`, optional 

150 Minimum cut on ADU for fit. 

151 max_adu : `float`, optional 

152 Maximum cut on ADU for fit. 

153 """ 

154 flux = 1000. 

155 time_vec = np.arange(1., 101., 5) 

156 k2_non_linearity = -5e-6 

157 coeff = k2_non_linearity/(flux**2.) 

158 

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

160 

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

162 

163 config = LinearitySolveTask.ConfigClass() 

164 config.linearityType = linearity_type 

165 config.minLinearAdu = min_adu 

166 config.maxLinearAdu = max_adu 

167 

168 task = LinearitySolveTask(config=config) 

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

170 

171 if linearity_type == "LookupTable": 

172 t_max = config.maxLookupTableAdu / flux 

173 time_range = np.linspace(0.0, t_max, config.maxLookupTableAdu) 

174 signal_ideal = time_range * flux 

175 signal_uncorrected = funcPolynomial(np.array([0.0, flux, k2_non_linearity]), time_range) 

176 linearizer_table_row = signal_ideal - signal_uncorrected 

177 

178 # Skip the last amp which is marked bad. 

179 for i, amp_name in enumerate(ptc.ampNames[:-1]): 

180 if linearity_type in ["Squared", "Polynomial"]: 

181 self.assertFloatsAlmostEqual(linearizer.fitParams[amp_name][0], 0.0, atol=1e-2) 

182 self.assertFloatsAlmostEqual(linearizer.fitParams[amp_name][1], 1.0, rtol=1e-5) 

183 self.assertFloatsAlmostEqual(linearizer.fitParams[amp_name][2], coeff, rtol=1e-6) 

184 

185 if linearity_type == "Polynomial": 

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

187 

188 if linearity_type == "Squared": 

189 self.assertEqual(len(linearizer.linearityCoeffs[amp_name]), 1) 

190 self.assertFloatsAlmostEqual(linearizer.linearityCoeffs[amp_name][0], -coeff, rtol=1e-6) 

191 else: 

192 self.assertEqual(len(linearizer.linearityCoeffs[amp_name]), 2) 

193 self.assertFloatsAlmostEqual(linearizer.linearityCoeffs[amp_name][0], -coeff, rtol=1e-6) 

194 self.assertFloatsAlmostEqual(linearizer.linearityCoeffs[amp_name][1], 0.0) 

195 

196 else: 

197 index = linearizer.linearityCoeffs[amp_name][0] 

198 self.assertEqual(index, i) 

199 self.assertEqual(len(linearizer.tableData[index, :]), len(linearizer_table_row)) 

200 self.assertFloatsAlmostEqual(linearizer.tableData[index, :], linearizer_table_row, rtol=1e-4) 

201 

202 lin_mask = np.isfinite(linearizer.fitResiduals[amp_name]) 

203 lin_mask_expected = (mu_vec > min_adu) & (mu_vec < max_adu) & ptc.expIdMask[amp_name] 

204 

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

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

207 

208 # If we apply the linearity correction, we should get the true 

209 # linear values out. 

210 image = lsst.afw.image.ImageF(len(mu_vec), 1) 

211 image.array[:, :] = mu_vec 

212 lin_func = linearizer.getLinearityTypeByName(linearizer.linearityType[amp_name]) 

213 lin_func()( 

214 image, 

215 coeffs=linearizer.linearityCoeffs[amp_name], 

216 table=linearizer.tableData, 

217 log=None, 

218 ) 

219 

220 linear_signal = flux * time_vec 

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

222 

223 self._check_linearizer_lengths(linearizer) 

224 

225 def _check_linearizer_lengths(self, linearizer): 

226 # Check that the lengths of all the fields match. 

227 lenCoeffs = -1 

228 lenParams = -1 

229 lenParamsErr = -1 

230 lenResiduals = -1 

231 lenFit = -1 

232 for ampName in linearizer.ampNames: 

233 if lenCoeffs < 0: 

234 lenCoeffs = len(linearizer.linearityCoeffs[ampName]) 

235 lenParams = len(linearizer.fitParams[ampName]) 

236 lenParamsErr = len(linearizer.fitParamsErr[ampName]) 

237 lenResiduals = len(linearizer.fitResiduals[ampName]) 

238 lenFit = len(linearizer.linearFit[ampName]) 

239 else: 

240 self.assertEqual( 

241 len(linearizer.linearityCoeffs[ampName]), 

242 lenCoeffs, 

243 msg=f"amp {ampName} linearityCoeffs length mismatch", 

244 ) 

245 self.assertEqual( 

246 len(linearizer.fitParams[ampName]), 

247 lenParams, 

248 msg=f"amp {ampName} fitParams length mismatch", 

249 ) 

250 self.assertEqual( 

251 len(linearizer.fitParamsErr[ampName]), 

252 lenParamsErr, 

253 msg=f"amp {ampName} fitParamsErr length mismatch", 

254 ) 

255 self.assertEqual( 

256 len(linearizer.fitResiduals[ampName]), 

257 lenResiduals, 

258 msg=f"amp {ampName} fitResiduals length mismatch", 

259 ) 

260 self.assertEqual( 

261 len(linearizer.linearFit[ampName]), 

262 lenFit, 

263 msg=f"amp {ampName} linearFit length mismatch", 

264 ) 

265 

266 def test_linearity_polynomial(self): 

267 """Test linearity with polynomial fit.""" 

268 self._check_linearity("Polynomial") 

269 

270 def test_linearity_squared(self): 

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

272 self._check_linearity("Squared") 

273 

274 def test_linearity_table(self): 

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

276 self._check_linearity("LookupTable") 

277 

278 def test_linearity_polynomial_aducuts(self): 

279 """Test linearity with polynomial and ADU cuts.""" 

280 self._check_linearity("Polynomial", min_adu=10000.0, max_adu=90000.0) 

281 

282 def _check_linearity_spline( 

283 self, 

284 do_pd_offsets=False, 

285 n_points=200, 

286 do_mu_offset=False, 

287 do_weight_fit=False, 

288 do_temperature_fit=False, 

289 ): 

290 """Check linearity with a spline solution. 

291 

292 Parameters 

293 ---------- 

294 do_pd_offsets : `bool`, optional 

295 Apply offsets to the photodiode data. 

296 do_mu_offset : `bool`, optional 

297 Apply constant offset to mu data. 

298 do_weight_fit : `bool`, optional 

299 Fit the weight parameters? 

300 do_temperature_fit : `bool`, optional 

301 Apply a temperature dependence and fit it? 

302 """ 

303 np.random.seed(12345) 

304 

305 # Create a test dataset representative of real data. 

306 pd_values = np.linspace(1e-8, 2e-5, n_points) 

307 time_values = pd_values * 1000000. 

308 linear_ratio = 5e9 

309 mu_linear = linear_ratio * pd_values 

310 

311 # Test spline parameters are taken from a test fit to LSSTCam 

312 # data, run 7193D, detector 22, amp C00. The exact fit is not 

313 # important, but this is only meant to be representative of 

314 # the shape of the non-linearity that we see. 

315 

316 n_nodes = 10 

317 

318 non_lin_spline_nodes = np.linspace(0, mu_linear.max(), n_nodes) 

319 non_lin_spline_values = np.array( 

320 [0.0, -8.87, 1.46, 1.69, -6.92, -68.23, -78.01, -11.56, 80.26, 185.01] 

321 ) 

322 

323 spl = lsst.afw.math.makeInterpolate( 

324 non_lin_spline_nodes, 

325 non_lin_spline_values, 

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

327 ) 

328 

329 mu_values = mu_linear + spl.interpolate(mu_linear) 

330 

331 # Add a temperature dependence if necessary. 

332 if do_temperature_fit: 

333 temp_coeff = 0.0006 

334 temperatures = np.random.normal(scale=0.5, size=len(mu_values)) - 100.0 

335 

336 # We use a negative sign here because we are doing the 

337 # opposite of the correction. 

338 mu_values *= (1 - temp_coeff*(temperatures - (-100.0))) 

339 else: 

340 temperatures = None 

341 

342 # Add a constant offset if necessary. 

343 if do_mu_offset: 

344 offset_value = 2.0 

345 mu_values += offset_value 

346 else: 

347 offset_value = 0.0 

348 

349 # Add some noise. 

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

351 

352 # Add some outlier values. 

353 if n_points >= 200: 

354 outlier_indices = np.arange(5) + 170 

355 else: 

356 outlier_indices = [] 

357 mu_values[outlier_indices] += 200.0 

358 

359 # Add some small offsets to the pd_values if requested. 

360 pd_values_offset = pd_values.copy() 

361 ccobcurr = None 

362 if do_pd_offsets: 

363 ccobcurr = np.zeros(pd_values.size) 

364 n_points_group = n_points//4 

365 group0 = np.arange(n_points_group) 

366 group1 = np.arange(n_points_group) + n_points_group 

367 group2 = np.arange(n_points_group) + 2*n_points_group 

368 group3 = np.arange(n_points_group) + 3*n_points_group 

369 ccobcurr[group0] = 0.01 

370 ccobcurr[group1] = 0.02 

371 ccobcurr[group2] = 0.03 

372 ccobcurr[group3] = 0.04 

373 

374 pd_offset_factors = [0.995, 1.0, 1.005, 1.002] 

375 pd_values_offset[group0] *= pd_offset_factors[0] 

376 pd_values_offset[group2] *= pd_offset_factors[2] 

377 pd_values_offset[group3] *= pd_offset_factors[3] 

378 

379 # Add one bad photodiode value, but don't put it at the very 

380 # end because that would change the spline node positions 

381 # and make comparisons to the "truth" here in the tests 

382 # more difficult. 

383 pd_values_offset[-2] = np.nan 

384 

385 ptc = self._create_ptc( 

386 self.amp_names, 

387 time_values, 

388 mu_values, 

389 ccobcurr=ccobcurr, 

390 photo_charges=pd_values_offset, 

391 temperatures=temperatures, 

392 ) 

393 

394 config = LinearitySolveTask.ConfigClass() 

395 config.linearityType = "Spline" 

396 config.usePhotodiode = True 

397 config.minLinearAdu = 0.0 

398 config.maxLinearAdu = np.nanmax(mu_values) + 1.0 

399 config.splineKnots = n_nodes 

400 config.splineGroupingMinPoints = 101 

401 config.doSplineFitOffset = do_mu_offset 

402 config.doSplineFitWeights = do_weight_fit 

403 config.splineFitWeightParsStart = [7.2e-5, 1e-4] 

404 config.doSplineFitTemperature = do_temperature_fit 

405 

406 if do_pd_offsets: 

407 config.splineGroupingColumn = "CCOBCURR" 

408 

409 if do_temperature_fit: 

410 config.splineFitTemperatureColumn = "TEMP6" 

411 

412 task = LinearitySolveTask(config=config) 

413 linearizer = task.run( 

414 ptc, 

415 [self.dummy_exposure], 

416 self.camera, 

417 self.input_dims, 

418 ).outputLinearizer 

419 

420 if do_weight_fit: 

421 # These checks currently fail, and weight fitting is not 

422 # recommended. 

423 return 

424 

425 # Skip the last amp which is marked bad. 

426 for amp_name in ptc.ampNames[:-1]: 

427 lin_mask = np.isfinite(linearizer.fitResiduals[amp_name]) 

428 

429 # Make sure that anything in the input mask is still masked. 

430 check, = np.where(~ptc.expIdMask[amp_name]) 

431 if len(check) > 0: 

432 self.assertEqual(np.all(lin_mask[check]), False) 

433 

434 # Make sure the outliers are masked. 

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

436 

437 # The first point at very low flux is noisier and so we exclude 

438 # it from the test here. 

439 resid_atol = 1.1e-3 

440 self.assertFloatsAlmostEqual( 

441 (linearizer.fitResiduals[amp_name][lin_mask] / mu_linear[lin_mask])[1:], 

442 0.0, 

443 atol=resid_atol, 

444 ) 

445 

446 # Loose check on the chi-squared. 

447 self.assertLess(linearizer.fitChiSq[amp_name], 2.0) 

448 

449 # Check the residual sigma_mad. 

450 self.assertLess(linearizer.fitResidualsSigmaMad[amp_name], 1.2e-4) 

451 

452 # If we apply the linearity correction, we should get the true 

453 # linear values out. 

454 image = lsst.afw.image.ImageF(len(mu_values), 1) 

455 image.array[:, :] = mu_values 

456 lin_func = linearizer.getLinearityTypeByName(linearizer.linearityType[amp_name]) 

457 lin_func()( 

458 image, 

459 coeffs=linearizer.linearityCoeffs[amp_name], 

460 log=None, 

461 ) 

462 

463 # We scale by the median because of ambiguity in the overall 

464 # gain parameter which is not part of the non-linearity. 

465 ratio = image.array[0, lin_mask]/mu_linear[lin_mask] 

466 # When we have an offset, this test gets a bit confused 

467 # mixing truth and offset values. 

468 ratio_rtol = 5e-2 if do_mu_offset else 5e-4 

469 self.assertFloatsAlmostEqual( 

470 ratio / np.median(ratio), 

471 1.0, 

472 rtol=ratio_rtol, 

473 ) 

474 

475 # Check that the spline parameters recovered are consistent, 

476 # with input to some low-grade precision. 

477 # The first element should be identically zero. 

478 self.assertFloatsEqual(linearizer.linearityCoeffs[amp_name][0], 0.0) 

479 

480 # We have two different comparisons here; for the terms that are 

481 # |value| < 20 (offset) or |value| > 20 (ratio), to avoid 

482 # divide-by-small-number problems. In all cases these are 

483 # approximate, and the real test is in the residuals. 

484 small = (np.abs(non_lin_spline_values) < 20) 

485 

486 spline_atol = 6.0 if do_pd_offsets else 2.0 

487 spline_rtol = 0.14 if do_pd_offsets else 0.05 

488 

489 self.assertFloatsAlmostEqual( 

490 linearizer.linearityCoeffs[amp_name][n_nodes:][small], 

491 non_lin_spline_values[small], 

492 atol=spline_atol, 

493 ) 

494 self.assertFloatsAlmostEqual( 

495 linearizer.linearityCoeffs[amp_name][n_nodes:][~small], 

496 non_lin_spline_values[~small], 

497 rtol=spline_rtol, 

498 ) 

499 

500 # And check the offsets if they were included. 

501 if do_pd_offsets: 

502 # The relative scaling is to group 1. 

503 fit_offset_factors = linearizer.fitParams[amp_name][1] / linearizer.fitParams[amp_name] 

504 extra_pars = 0 

505 if do_mu_offset: 

506 extra_pars += 1 

507 if do_temperature_fit: 

508 extra_pars += 1 

509 

510 if extra_pars > 0: 

511 fit_offset_factors = fit_offset_factors[:-extra_pars] 

512 

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

514 

515 # And check if the offset is fit well. 

516 fit_offset = None 

517 fit_temp_coeff = None 

518 if do_mu_offset and do_temperature_fit: 

519 fit_offset = linearizer.fitParams[amp_name][-2] 

520 fit_temp_coeff = linearizer.fitParams[amp_name][-1] 

521 elif do_mu_offset: 

522 fit_offset = linearizer.fitParams[amp_name][-1] 

523 elif do_temperature_fit: 

524 fit_temp_coeff = linearizer.fitParams[amp_name][-1] 

525 

526 if fit_offset is not None: 

527 self.assertFloatsAlmostEqual(fit_offset, offset_value, rtol=6e-3) 

528 

529 if fit_temp_coeff is not None: 

530 self.assertFloatsAlmostEqual(fit_temp_coeff, temp_coeff, rtol=2e-2) 

531 

532 self._check_linearizer_lengths(linearizer) 

533 

534 def test_linearity_spline(self): 

535 self._check_linearity_spline(do_pd_offsets=False, do_mu_offset=False) 

536 

537 def test_linearity_spline_offsets(self): 

538 self._check_linearity_spline(do_pd_offsets=True, do_mu_offset=False) 

539 

540 def test_linearity_spline_mu_offset(self): 

541 self._check_linearity_spline(do_pd_offsets=True, do_mu_offset=True) 

542 

543 def test_linearity_spline_fit_weights(self): 

544 self._check_linearity_spline(do_pd_offsets=True, do_mu_offset=True, do_weight_fit=True) 

545 

546 def test_linearity_spline_fit_temperature(self): 

547 self._check_linearity_spline(do_pd_offsets=True, do_mu_offset=True, do_temperature_fit=True) 

548 

549 def test_linearity_spline_offsets_too_few_points(self): 

550 with self.assertRaisesRegex(RuntimeError, "too few points"): 

551 self._check_linearity_spline(do_pd_offsets=True, n_points=100) 

552 

553 

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

555 pass 

556 

557 

558def setup_module(module): 

559 lsst.utils.tests.init() 

560 

561 

562if __name__ == "__main__": 562 ↛ 563line 562 didn't jump to line 563, because the condition on line 562 was never true

563 lsst.utils.tests.init() 

564 unittest.main()