Coverage for tests/test_ampOffset.py: 21%

136 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-10 04:06 -0700

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 return exp 

277 

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

279 """ 

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

281 

282 Parameters 

283 ---------- 

284 valueType : `str` 

285 Determines the distribution type of values across the amplifiers. 

286 See `buildExposure` for details. 

287 

288 rampBackground : `bool` 

289 Whether the added background should be a ramp. 

290 """ 

291 if rampBackground: 

292 measuredPedestals = self.measuredPedestalsRampBackground 

293 measuredSigma = self.measuredSigmaRampBackground 

294 else: 

295 measuredPedestals = self.measuredPedestalsConstantBackground 

296 measuredSigma = self.measuredSigmaConstantBackground 

297 

298 for applyWeights in [False, True]: 

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

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

301 config = AmpOffsetConfig() 

302 config.doBackground = True 

303 config.doDetection = True 

304 config.ampEdgeWidth = 12 

305 config.applyWeights = applyWeights 

306 if valueType == "random": 

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

308 # amp interface 01 is unusually small. 

309 config.ampEdgeMinFrac = 0.1 

310 if valueType == "artificial": 

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

312 # unusually large. 

313 config.ampEdgeMaxOffset = 50 

314 task = AmpOffsetTask(config=config) 

315 pedestals = task.run(exp).pedestals 

316 nAmps = len(amps) 

317 if valueType == "symmetric": 

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

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

320 

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

322 maskedImage = exp.getMaskedImage() 

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

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

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

326 bgSubtractedValues = [] 

327 for i, bbox in enumerate(ampBBoxes): 

328 ampBgImage = bg.getImageF( 

329 interpStyle=task.background.config.algorithm, 

330 undersampleStyle=task.background.config.undersampleStyle, 

331 bbox=bbox, 

332 ) 

333 if not rampBackground: 

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

335 else: 

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

337 # proper approximation. 

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

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

340 

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

342 

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

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

345 self.assertAlmostEqual(pedestal, value, 5) 

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

347 # some specified DN. 

348 self.assertAlmostEqual( 

349 np.std(pedestals - approximatePedestals), 

350 measuredSigma[weightType][valueType], 

351 12, 

352 ) 

353 if valueType == "artificial": 

354 if not applyWeights: 

355 sigmaUnweighted = np.std(pedestals - approximatePedestals) 

356 else: 

357 # Verify that the weighted sigma differs from the 

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

359 # sigma to be consistently smaller, given that the 

360 # estimated background isn't uniform and our expected 

361 # pedestals are approximations. 

362 sigmaWeighted = np.std(pedestals - approximatePedestals) 

363 self.assertNotEqual(sigmaWeighted, sigmaUnweighted) 

364 

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

366 def testAmpOffset(self, valueType): 

367 for applyWeights in [False, True]: 

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

369 config = AmpOffsetConfig() 

370 config.doBackground = False 

371 config.doDetection = False 

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

373 if valueType == "artificial": 

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

375 # unusually large. 

376 config.ampEdgeMaxOffset = 50 

377 config.applyWeights = applyWeights 

378 task = AmpOffsetTask(config=config) 

379 pedestals = task.run(exp).pedestals 

380 if valueType == "symmetric": 

381 self.assertEqual(np.sum(exp.image.array), 0) 

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

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

384 self.assertAlmostEqual(pedestal, value, 6) 

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

386 self.assertAlmostEqual( 

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

388 ) 

389 if valueType == "artificial": 

390 if not applyWeights: 

391 sigmaUnweighted = np.std(pedestals - truePedestals) 

392 else: 

393 # Verify that the weighted sigma differs from the 

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

395 # sigma to be consistently smaller, given the numerical 

396 # noise from exceedingly small value calculations and the 

397 # variations in library versions and operating systems. 

398 sigmaWeighted = np.std(pedestals - truePedestals) 

399 self.assertNotEqual(sigmaWeighted, sigmaUnweighted) 

400 

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

402 def testAmpOffsetWithConstantBackground(self, valueType): 

403 self.runAmpOffsetWithBackground(valueType, rampBackground=False) 

404 

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

406 def testAmpOffsetWithRampBackground(self, valueType): 

407 self.runAmpOffsetWithBackground(valueType, rampBackground=True) 

408 

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

410 @staticmethod 

411 def amplifierAddYGradient(ampData, start, end): 

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

413 ampArr = ampData.array 

414 ampArr[:] = ampArr[:] + ( 

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

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

417 ) 

418 

419 @staticmethod 

420 def amplifierAddXGradient(ampData, start, end): 

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

422 ampArr = ampData.array 

423 ampArr[:] = ampArr[:] + ( 

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

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

426 ) 

427 

428 

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

430 pass 

431 

432 

433def setup_module(module): 

434 lsst.utils.tests.init() 

435 

436 

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

438 lsst.utils.tests.init() 

439 unittest.main()