Coverage for tests/test_ampOffset.py: 21%
136 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-02 04:05 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-02 04:05 -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
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 return exp
278 def runAmpOffsetWithBackground(self, valueType, rampBackground=False):
279 """
280 Tests the AmpOffsetTask on an exposure with a background added.
282 Parameters
283 ----------
284 valueType : `str`
285 Determines the distribution type of values across the amplifiers.
286 See `buildExposure` for details.
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
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)
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))
341 approximatePedestals = np.array(bgSubtractedValues) - np.mean(bgSubtractedValues)
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)
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)
401 @methodParameters(valueType=["symmetric", "random", "artificial"])
402 def testAmpOffsetWithConstantBackground(self, valueType):
403 self.runAmpOffsetWithBackground(valueType, rampBackground=False)
405 @methodParameters(valueType=["symmetric", "random", "artificial"])
406 def testAmpOffsetWithRampBackground(self, valueType):
407 self.runAmpOffsetWithBackground(valueType, rampBackground=True)
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 )
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 )
429class TestMemory(lsst.utils.tests.MemoryTestCase):
430 pass
433def setup_module(module):
434 lsst.utils.tests.init()
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()