Coverage for tests / test_ampOffset.py: 15%

153 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-28 08:55 +0000

1# This file is part of ip_isr. 

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

21import unittest 

22import numpy as np 

23 

24import lsst.utils.tests 

25from lsst.utils.tests import methodParameters 

26from lsst.ip.isr.ampOffset import AmpOffsetConfig, AmpOffsetTask 

27from lsst.ip.isr.isrMock import IsrMock 

28 

29# The following values are used to test the AmpOffsetTask. 

30BACKGROUND_VALUE = 100 

31RAMP_XSCALE = 0.0 

32RAMP_YSCALE = 100.0 

33 

34 

35class AmpOffsetTest(lsst.utils.tests.TestCase): 

36 def setUp(self): 

37 # Testing with a single detector that has 8 amplifiers in a 4x2 

38 # configuration to ensure functionality in a general 2-dimensional 

39 # scenario. Each amplifier measures 100x51 in dimensions. 

40 config = IsrMock.ConfigClass() 

41 config.isLsstLike = True 

42 config.doAddBias = False 

43 config.doAddDark = False 

44 config.doAddFlat = False 

45 config.doAddFringe = False 

46 config.doGenerateImage = True 

47 config.doGenerateData = True 

48 config.doGenerateAmpDict = True 

49 self.mock = IsrMock(config=config) 

50 self.measuredPedestalsConstantBackground = { 

51 "unweighted": { 

52 "symmetric": [ 

53 -1.36344239, 

54 -0.997554, 

55 -0.4337341, 

56 -0.06784741, 

57 0.06784741, 

58 0.43373411, 

59 0.99755399, 

60 1.36344238, 

61 ], 

62 "random": [ 

63 1.2545466, 

64 -1.42373527, 

65 -0.32187415, 

66 -0.61296588, 

67 0.44789211, 

68 0.21339304, 

69 -0.6362251, 

70 1.07896865, 

71 ], 

72 "artificial": [ 

73 -3.96260237, 

74 -6.31372346, 

75 -5.54533972, 

76 -9.8481738, 

77 8.68735117, 

78 5.97670723, 

79 3.85524471, 

80 7.15053624, 

81 ], 

82 }, 

83 "weighted": { 

84 "symmetric": [ 

85 -1.36344235, 

86 -0.99755403, 

87 -0.43373414, 

88 -0.06784737, 

89 0.06784738, 

90 0.43373415, 

91 0.99755403, 

92 1.36344234, 

93 ], 

94 "random": [ 

95 1.28420419, 

96 -1.42457154, 

97 -0.34101654, 

98 -0.62264482, 

99 0.41823451, 

100 0.21422931, 

101 -0.61708271, 

102 1.0886476, 

103 ], 

104 "artificial": [ 

105 -3.96861485, 

106 -6.30713176, 

107 -5.5651146, 

108 -9.82897816, 

109 8.69336365, 

110 5.97011553, 

111 3.87501958, 

112 7.13134059, 

113 ], 

114 }, 

115 } 

116 self.measuredPedestalsRampBackground = { 

117 "unweighted": { 

118 "symmetric": [ 

119 -1.35330781, 

120 -0.92859869, 

121 -0.50268509, 

122 -0.07798556, 

123 0.0779834, 

124 0.50268579, 

125 0.92859927, 

126 1.35330869, 

127 ], 

128 "random": [ 

129 1.31054124, 

130 -1.64586418, 

131 -0.15561936, 

132 -0.54440476, 

133 0.30516155, 

134 0.25678385, 

135 -0.78329776, 

136 1.25669942, 

137 ], 

138 "artificial": [ 

139 -3.92486412, 

140 -6.64218097, 

141 -4.85473319, 

142 -9.58046338, 

143 8.67801534, 

144 5.66513899, 

145 3.59097321, 

146 7.06811412, 

147 ], 

148 }, 

149 "weighted": { 

150 "symmetric": [ 

151 -1.35330818, 

152 -0.92859835, 

153 -0.5026847, 

154 -0.07798592, 

155 0.07798378, 

156 0.50268544, 

157 0.92859888, 

158 1.35330905, 

159 ], 

160 "random": [ 

161 1.31103334, 

162 -1.63924511, 

163 -0.16414238, 

164 -0.54299292, 

165 0.30466945, 

166 0.25016478, 

167 -0.77477473, 

168 1.25528757, 

169 ], 

170 "artificial": [ 

171 -3.93099297, 

172 -6.63242338, 

173 -4.86026077, 

174 -9.57856454, 

175 8.68414419, 

176 5.6553814, 

177 3.5965008, 

178 7.06621528, 

179 ], 

180 }, 

181 } 

182 self.measuredSigma = { 

183 "unweighted": { 

184 "symmetric": 3.550909965491515e-08, 

185 "random": 9.271480035637249e-08, 

186 "artificial": 3.9430895759341275e-14, 

187 }, 

188 "weighted": { 

189 "symmetric": 3.6093886632617167e-08, 

190 "random": 9.280982569622311e-08, 

191 "artificial": 8.690997162288794e-15, 

192 }, 

193 } 

194 self.measuredSigmaConstantBackground = { 

195 "unweighted": { 

196 "symmetric": 0.06679099233901276, 

197 "random": 0.12836424357658588, 

198 "artificial": 0.5766923710558869, 

199 }, 

200 "weighted": { 

201 "symmetric": 0.06679099233983563, 

202 "random": 0.12854047710163782, 

203 "artificial": 0.579062202931159, 

204 }, 

205 } 

206 self.measuredSigmaRampBackground = { 

207 "unweighted": { 

208 "symmetric": 0.8049174282700369, 

209 "random": 0.8128787501698868, 

210 "artificial": 6.090700238137678, 

211 }, 

212 "weighted": { 

213 "symmetric": 0.8049174282702547, 

214 "random": 0.8089364786360683, 

215 "artificial": 6.089900455983161, 

216 }, 

217 } 

218 

219 def tearDown(self): 

220 del self.mock 

221 

222 def buildExposure(self, valueType, addBackground=False, rampBackground=False): 

223 """ 

224 Build and return an exposure with different types of value 

225 distributions across its amplifiers. 

226 

227 Parameters 

228 ---------- 

229 valueType : `str` 

230 Determines the distribution type of values across the amplifiers. 

231 - "symmetric": Creates a symmetric constant interval distribution 

232 of values. 

233 - "random": Generates a random distribution of values. 

234 - "artificial": Uses a predefined array of values to simulate a 

235 weight-sensitive condition for the output pedestals. This set of 

236 values designed to show more change across the short interface and 

237 will not solve exactly. 

238 

239 addBackground : `bool`, optional 

240 If True, adds a background value to the entire exposure. 

241 

242 rampBackground : `bool`, optional 

243 Whether the added background should be a ramp. 

244 

245 Returns 

246 ------- 

247 exp : `~lsst.afw.image.Exposure` 

248 An exposure object modified according to the specified valueType 

249 and background addition. 

250 

251 Notes 

252 ----- 

253 This method is used to generate different scenarios of exposure data 

254 for testing and analysis. The 'artificial' valueType is particularly 

255 useful for testing the robustness of algorithms under non-ideal or 

256 challenging data conditions. 

257 """ 

258 exp = self.mock.getExposure() 

259 detector = exp.getDetector() 

260 amps = detector.getAmplifiers() 

261 values = { 

262 "symmetric": np.linspace(-2.5, 2.5, len(amps)), 

263 "random": np.random.RandomState(seed=1746).uniform(-2.5, 2.5, len(amps)), 

264 "artificial": np.array([5, 1, 3, -4, 30, 25, 22, 27]), 

265 } 

266 self.values = values[valueType] 

267 for amp, value in zip(amps, self.values): 

268 exp.image[amp.getBBox()] = value 

269 if addBackground: 

270 exp.image.array += BACKGROUND_VALUE 

271 if rampBackground: 

272 # Add a gradient. 

273 self.amplifierAddYGradient(exp.image, 0.0, RAMP_YSCALE) 

274 # Add another gradient to the other direction. 

275 self.amplifierAddXGradient(exp.image, 0.0, RAMP_XSCALE) 

276 else: 

277 assert not rampBackground, "rampBackground requires addBackground=True" 

278 return exp 

279 

280 def runAmpOffsetWithBackground(self, valueType, rampBackground=False): 

281 """ 

282 Tests the AmpOffsetTask on an exposure with a background added. 

283 

284 Parameters 

285 ---------- 

286 valueType : `str` 

287 Determines the distribution type of values across the amplifiers. 

288 See `buildExposure` for details. 

289 

290 rampBackground : `bool` 

291 Whether the added background should be a ramp. 

292 """ 

293 if rampBackground: 

294 measuredPedestals = self.measuredPedestalsRampBackground 

295 measuredSigma = self.measuredSigmaRampBackground 

296 else: 

297 measuredPedestals = self.measuredPedestalsConstantBackground 

298 measuredSigma = self.measuredSigmaConstantBackground 

299 

300 for applyWeights in [False, True]: 

301 exp = self.buildExposure(valueType, addBackground=True, rampBackground=rampBackground) 

302 amps = exp.getDetector().getAmplifiers() 

303 config = AmpOffsetConfig() 

304 config.doBackground = True 

305 config.doDetection = True 

306 config.ampEdgeWidth = 12 

307 config.applyWeights = applyWeights 

308 config.doApplyAmpOffset = True # Updates the exposure in place. 

309 if valueType == "random": 

310 # For this specific case, the fraction of unmasked pixels for 

311 # amp interface 01 is unusually small. 

312 config.ampEdgeMinFrac = 0.1 

313 if valueType == "artificial": 

314 # For this extreme case, we expect the interface offsets to be 

315 # unusually large. 

316 config.ampEdgeMaxOffset = 50 

317 task = AmpOffsetTask(config=config) 

318 pedestals = task.run(exp).pedestals 

319 nAmps = len(amps) 

320 if valueType == "symmetric": 

321 for i in range(nAmps // 2): 

322 self.assertAlmostEqual(pedestals[i], -pedestals[nAmps - i - 1], 5) 

323 

324 ampBBoxes = [amp.getBBox() for amp in amps] 

325 maskedImage = exp.getMaskedImage() 

326 nX = exp.getWidth() // (task.shortAmpSide * config.backgroundFractionSample) + 1 

327 nY = exp.getHeight() // (task.shortAmpSide * config.backgroundFractionSample) + 1 

328 bg = task.background.fitBackground(maskedImage, nx=int(nX), ny=int(nY)) 

329 bgSubtractedValues = [] 

330 for i, bbox in enumerate(ampBBoxes): 

331 ampBgImage = bg.getImageF( 

332 interpStyle=task.background.config.algorithm, 

333 undersampleStyle=task.background.config.undersampleStyle, 

334 bbox=bbox, 

335 ) 

336 if not rampBackground: 

337 bgSubtractedValues.append(self.values[i] + BACKGROUND_VALUE - np.mean(ampBgImage.array)) 

338 else: 

339 # With the added gradient, averaging is required for a 

340 # proper approximation. 

341 meanValueWithBg = exp.image[bbox].array.mean() 

342 bgSubtractedValues.append(meanValueWithBg - np.mean(ampBgImage.array)) 

343 

344 approximatePedestals = np.array(bgSubtractedValues) - np.mean(bgSubtractedValues) 

345 

346 weightType = "weighted" if applyWeights else "unweighted" 

347 for pedestal, value in zip(pedestals, measuredPedestals[weightType][valueType]): 

348 self.assertAlmostEqual(pedestal, value, 5) 

349 # If we are getting it wrong, let's not get it wrong by more than 

350 # some specified DN. 

351 self.assertAlmostEqual( 

352 np.std(pedestals - approximatePedestals), 

353 measuredSigma[weightType][valueType], 

354 4 if rampBackground else 12, 

355 ) 

356 if valueType == "artificial": 

357 if not applyWeights: 

358 sigmaUnweighted = np.std(pedestals - approximatePedestals) 

359 else: 

360 # Verify that the weighted sigma differs from the 

361 # unweighted sigma. It's not anticipated for the weighted 

362 # sigma to be consistently smaller, given that the 

363 # estimated background isn't uniform and our expected 

364 # pedestals are approximations. 

365 sigmaWeighted = np.std(pedestals - approximatePedestals) 

366 self.assertNotEqual(sigmaWeighted, sigmaUnweighted) 

367 

368 def testAmpOffsetEffectOnExposure(self): 

369 exp0 = self.buildExposure("random", addBackground=True, rampBackground=True) 

370 exp = exp0.clone() 

371 config = AmpOffsetConfig() 

372 config.doBackground = True 

373 config.doDetection = True 

374 config.ampEdgeWidth = 12 

375 config.applyWeights = True 

376 

377 # Configure to not apply amp offset to the exposure and run the task. 

378 # Verify that the exposure remains unchanged. 

379 config.doApplyAmpOffset = False 

380 AmpOffsetTask(config=config).run(exp) 

381 self.assertFloatsEqual(exp0.image.array, exp.image.array) 

382 

383 # Configure to apply amp offset to the exposure and run the task. 

384 # Verify that the exposure is updated. 

385 config.doApplyAmpOffset = True 

386 AmpOffsetTask(config=config).run(exp) 

387 self.assertFloatsNotEqual(exp0.image.array, exp.image.array) 

388 

389 @methodParameters(valueType=["symmetric", "random", "artificial"]) 

390 def testAmpOffset(self, valueType): 

391 for applyWeights in [False, True]: 

392 exp = self.buildExposure(valueType, addBackground=False) 

393 config = AmpOffsetConfig() 

394 config.doBackground = False 

395 config.doDetection = False 

396 config.ampEdgeWidth = 12 # Given 100x51 amps in our mock detector. 

397 config.doApplyAmpOffset = True # Updates the exposure in place. 

398 if valueType == "artificial": 

399 # For this extreme case, we expect the interface offsets to be 

400 # unusually large. 

401 config.ampEdgeMaxOffset = 50 

402 config.applyWeights = applyWeights 

403 task = AmpOffsetTask(config=config) 

404 pedestals = task.run(exp).pedestals 

405 if valueType == "symmetric": 

406 self.assertAlmostEqual(np.sum(exp.image.array), 0, 6) 

407 truePedestals = self.values - np.mean(self.values) 

408 for pedestal, value in zip(pedestals, truePedestals): 

409 self.assertAlmostEqual(pedestal, value, 6) 

410 weightType = "weighted" if applyWeights else "unweighted" 

411 self.assertAlmostEqual( 

412 np.std(pedestals - truePedestals), self.measuredSigma[weightType][valueType], 12 

413 ) 

414 if valueType == "artificial": 

415 if not applyWeights: 

416 sigmaUnweighted = np.std(pedestals - truePedestals) 

417 else: 

418 # Verify that the weighted sigma differs from the 

419 # unweighted sigma. It's not anticipated for the weighted 

420 # sigma to be consistently smaller, given the numerical 

421 # noise from exceedingly small value calculations and the 

422 # variations in library versions and operating systems. 

423 sigmaWeighted = np.std(pedestals - truePedestals) 

424 self.assertNotEqual(sigmaWeighted, sigmaUnweighted) 

425 

426 @methodParameters(valueType=["symmetric", "random", "artificial"]) 

427 def testAmpOffsetWithConstantBackground(self, valueType): 

428 self.runAmpOffsetWithBackground(valueType, rampBackground=False) 

429 

430 @methodParameters(valueType=["symmetric", "random", "artificial"]) 

431 def testAmpOffsetWithRampBackground(self, valueType): 

432 self.runAmpOffsetWithBackground(valueType, rampBackground=True) 

433 

434 # The two static methods below are taken from ip_isr/isrMock. 

435 @staticmethod 

436 def amplifierAddYGradient(ampData, start, end): 

437 nPixY = ampData.getDimensions().getY() 

438 ampArr = ampData.array 

439 ampArr[:] = ampArr[:] + ( 

440 np.interp(range(nPixY), (0, nPixY - 1), (start, end)).reshape(nPixY, 1) 

441 + np.zeros(ampData.getDimensions()).transpose() 

442 ) 

443 

444 @staticmethod 

445 def amplifierAddXGradient(ampData, start, end): 

446 nPixX = ampData.getDimensions().getX() 

447 ampArr = ampData.array 

448 ampArr[:] = ampArr[:] + ( 

449 np.interp(range(nPixX), (0, nPixX - 1), (start, end)).reshape(1, nPixX) 

450 + np.zeros(ampData.getDimensions()).transpose() 

451 ) 

452 

453 

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

455 pass 

456 

457 

458def setup_module(module): 

459 lsst.utils.tests.init() 

460 

461 

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

463 lsst.utils.tests.init() 

464 unittest.main()