Coverage for tests / test_ampOffset.py: 15%
153 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 09:00 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 09:00 +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
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
29# The following values are used to test the AmpOffsetTask.
30BACKGROUND_VALUE = 100
31RAMP_XSCALE = 0.0
32RAMP_YSCALE = 100.0
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 }
219 def tearDown(self):
220 del self.mock
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.
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.
239 addBackground : `bool`, optional
240 If True, adds a background value to the entire exposure.
242 rampBackground : `bool`, optional
243 Whether the added background should be a ramp.
245 Returns
246 -------
247 exp : `~lsst.afw.image.Exposure`
248 An exposure object modified according to the specified valueType
249 and background addition.
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
280 def runAmpOffsetWithBackground(self, valueType, rampBackground=False):
281 """
282 Tests the AmpOffsetTask on an exposure with a background added.
284 Parameters
285 ----------
286 valueType : `str`
287 Determines the distribution type of values across the amplifiers.
288 See `buildExposure` for details.
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
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)
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))
344 approximatePedestals = np.array(bgSubtractedValues) - np.mean(bgSubtractedValues)
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)
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
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)
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)
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)
426 @methodParameters(valueType=["symmetric", "random", "artificial"])
427 def testAmpOffsetWithConstantBackground(self, valueType):
428 self.runAmpOffsetWithBackground(valueType, rampBackground=False)
430 @methodParameters(valueType=["symmetric", "random", "artificial"])
431 def testAmpOffsetWithRampBackground(self, valueType):
432 self.runAmpOffsetWithBackground(valueType, rampBackground=True)
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 )
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 )
454class TestMemory(lsst.utils.tests.MemoryTestCase):
455 pass
458def setup_module(module):
459 lsst.utils.tests.init()
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()