Coverage for tests/test_CompensatedGaussianFlux.py: 11%
155 statements
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-01 13:06 +0000
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-01 13:06 +0000
1# This file is part of meas_base.
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/>.
22import unittest
24import numpy as np
26import lsst.geom
27import lsst.afw.geom
28import lsst.meas.base
29import lsst.utils.tests
30from lsst.meas.base.tests import AlgorithmTestCase
31from lsst.meas.base._measBaseLib import _compensatedGaussianFiltInnerProduct
34class CompensatedGaussianFluxTestCase(AlgorithmTestCase, lsst.utils.tests.TestCase):
35 def setUp(self):
36 self.bbox = lsst.geom.Box2I(lsst.geom.Point2I(-20, -30),
37 lsst.geom.Extent2I(1000, 1000))
39 self.dataset = lsst.meas.base.tests.TestDataset(self.bbox)
40 self.psf_size = 2.0
41 self.dataset.psfShape = lsst.afw.geom.Quadrupole(self.psf_size**2., self.psf_size**2., 0.0)
43 # We want a set of point sources at various flux levels.
44 self.dataset.addSource(10000.0, lsst.geom.Point2D(50.1, 49.8))
45 self.dataset.addSource(20000.0, lsst.geom.Point2D(100.5, 100.4))
46 self.dataset.addSource(30000.0, lsst.geom.Point2D(150.4, 149.6))
47 self.dataset.addSource(40000.0, lsst.geom.Point2D(200.2, 200.3))
48 self.dataset.addSource(50000.0, lsst.geom.Point2D(250.3, 250.1))
49 self.dataset.addSource(60000.0, lsst.geom.Point2D(300.4, 300.2))
50 self.dataset.addSource(70000.0, lsst.geom.Point2D(350.5, 350.6))
51 self.dataset.addSource(80000.0, lsst.geom.Point2D(400.6, 400.0))
52 self.dataset.addSource(90000.0, lsst.geom.Point2D(450.0, 450.0))
53 self.dataset.addSource(100000.0, lsst.geom.Point2D(500.7, 500.8))
55 # Small test for Monte Carlo
56 self.bbox_single = lsst.geom.Box2I(lsst.geom.Point2I(0, 0),
57 lsst.geom.Extent2I(101, 101))
58 self.dataset_single = lsst.meas.base.tests.TestDataset(self.bbox_single)
59 self.dataset_single.psfShape = self.dataset.psfShape
61 self.dataset_single.addSource(100000.0, lsst.geom.Point2D(50.0, 50.0))
63 self.all_widths = (2, 3, 5)
64 self.larger_widths = (3, 5)
65 self.all_ts = (1.5, 2.0)
67 def tearDown(self):
68 del self.bbox
69 del self.dataset
70 del self.bbox_single
71 del self.dataset_single
73 def makeAlgorithm(self, config=None):
74 """Construct an algorithm and return both it and its schema.
75 """
76 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema()
77 if config is None:
78 config = lsst.meas.base.SingleFrameCompensatedGaussianFluxConfig()
79 algorithm = lsst.meas.base.SingleFrameCompensatedGaussianFluxPlugin(
80 config,
81 "base_CompensatedGaussianFlux",
82 schema,
83 None
84 )
85 return algorithm, schema
87 def _calcNumpyCompensatedGaussian(self, arr, var_arr, x_cent, y_cent, width, t):
88 """Calculate the compensated Gaussian using numpy (for testing).
90 Parameters
91 ----------
92 arr : `np.ndarray`
93 Array of pixel values.
94 var_arr : `np.ndarray`
95 Array of variance values.
96 x_cent : `float`
97 x value of centroid.
98 y_cent : `float`
99 y value of centroid.
100 width : `float`
101 Width of inner kernel.
102 t : `float`
103 Scaling factor for outer kernel (outer_width = width*t).
105 Returns
106 -------
107 flux : `float`
108 variance : `float`
109 """
110 xx, yy = np.meshgrid(np.arange(arr.shape[0]), np.arange(arr.shape[1]))
111 # Compute the inner and outer normalized Gaussian weights.
112 inner = (1./(2.*np.pi*width**2.))*np.exp(-0.5*(((xx - x_cent)/width)**2. + ((yy - y_cent)/width)**2.))
113 outer = (1./(2.*np.pi*(t*width)**2.))*np.exp(-0.5*(((xx - x_cent)/(t*width))**2.
114 + ((yy - y_cent)/(t*width))**2.))
115 weight = inner - outer
117 # Compute the weighted sum of the pixels.
118 flux = np.sum(weight*arr)
119 # And the normalization term, derived in Lupton et al. (in prep).
120 flux *= 4.*np.pi*(width**2.)*(t**2. + 1)/(t**2. - 1)
122 # And compute the variance
123 variance = np.sum(weight*weight*var_arr)
124 variance /= np.sum(weight*weight)
126 # The variance normalization term, derived in Lupton et al. (in prep).
127 variance *= 4.*np.pi*(width**2.)*(t**2 + 1)/t**2.
129 return flux, variance
131 def testCompensatedGaussianInnerProduct(self):
132 """Test using the inner product routine directly."""
134 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0),
135 lsst.geom.Extent2I(101, 101))
136 dataset = lsst.meas.base.tests.TestDataset(bbox)
137 psf_size = 2.0
138 dataset.psfShape = lsst.afw.geom.Quadrupole(psf_size**2., psf_size**2., 0.0)
139 centroid = lsst.geom.Point2D(50.0, 50.0)
140 true_flux = 100000.0
141 dataset.addSource(true_flux, centroid)
143 # We need to set up the task in order to create the test dataset.
144 task = self.makeSingleFrameMeasurementTask("base_CompensatedGaussianFlux")
146 for width in self.all_widths:
147 for t in self.all_ts:
148 flux_0 = None
149 var_0 = None
150 for dc_offset in (0.0, -1.0, 1.0):
151 exposure, catalog = dataset.realize(10.0, task.schema, randomSeed=1000)
153 exposure.image.array += dc_offset
155 flux, var = _compensatedGaussianFiltInnerProduct(
156 exposure.image.array,
157 exposure.variance.array,
158 centroid.getX(),
159 centroid.getY(),
160 width,
161 t,
162 )
164 flux_numpy, var_numpy = self._calcNumpyCompensatedGaussian(
165 exposure.image.array,
166 exposure.variance.array,
167 centroid.getX(),
168 centroid.getY(),
169 width,
170 t,
171 )
173 # Compare values from c++ code to numpy code.
174 self.assertFloatsAlmostEqual(flux, flux_numpy, rtol=1e-6)
175 self.assertFloatsAlmostEqual(var, var_numpy, rtol=1e-6)
177 # If the kernel width is equal to the simulated PSF then it
178 # should be nearly unbiased.
179 if width == psf_size:
180 self.assertFloatsAlmostEqual(flux, true_flux, rtol=1e-3)
182 # And check biases with non-zero DC offset; these should be
183 # equal with some floating point tolerance.
184 if dc_offset == 0.0:
185 flux_0 = flux
186 var_0 = var
187 else:
188 self.assertFloatsAlmostEqual(flux, flux_0, rtol=1e-7)
189 self.assertFloatsAlmostEqual(var, var_0, rtol=1e-7)
191 def testCompensatedGaussianSubPixels(self):
192 """Test for correct instFlux as a function of sub-pixel position."""
193 np.random.seed(12345)
195 n_points = 100
197 x_sub = np.random.uniform(low=0.0, high=1.0, size=n_points)
198 y_sub = np.random.uniform(low=0.0, high=1.0, size=n_points)
200 # We need to set up the task in order to create the test dataset.
201 task = self.makeSingleFrameMeasurementTask("base_CompensatedGaussianFlux")
203 for width in self.all_widths:
204 for t in self.all_ts:
205 fluxes = np.zeros(n_points)
207 for i in range(n_points):
208 dataset = lsst.meas.base.tests.TestDataset(self.bbox_single)
209 centroid = lsst.geom.Point2D(50.0 + x_sub[i], 50.0 + y_sub[i])
210 dataset.addSource(50000.0, centroid)
212 exposure, catalog = dataset.realize(10.0, task.schema, randomSeed=i)
213 flux, var = _compensatedGaussianFiltInnerProduct(
214 exposure.image.array,
215 exposure.variance.array,
216 centroid.getX(),
217 centroid.getY(),
218 width,
219 t,
220 )
222 fluxes[i] = flux
224 # Check for no correlation with x_sub and y_sub.
225 fit_x = np.polyfit(x_sub, fluxes/50000.0, 1)
226 self.assertLess(np.abs(fit_x[0]), 1e-3)
227 fit_y = np.polyfit(y_sub, fluxes/50000.0, 1)
228 self.assertLess(np.abs(fit_y[0]), 1e-3)
230 def testCompensatedGaussianPlugin(self):
231 """Test for correct instFlux given known position and shape.
232 """
233 # In the z-band, HSC images have a noise of about 40.0 ADU, and a background
234 # offset of ~ -0.6 ADU/pixel. This determines our test levels.
235 for width in self.all_widths:
236 for t in self.all_ts:
237 for dc_offset in (0.0, -1.0, 1.0):
238 config = self.makeSingleFrameMeasurementConfig("base_CompensatedGaussianFlux")
239 config.algorithms["base_CompensatedGaussianFlux"].kernel_widths = [width]
240 config.algorithms["base_CompensatedGaussianFlux"].t = t
242 task = self.makeSingleFrameMeasurementTask(config=config)
243 exposure, catalog = self.dataset.realize(40.0, task.schema, randomSeed=0)
244 exposure.image.array += dc_offset
245 task.run(catalog, exposure)
247 filter_flux = catalog[f"base_CompensatedGaussianFlux_{width}_instFlux"]
248 filter_err = catalog[f"base_CompensatedGaussianFlux_{width}_instFluxErr"]
249 truth_flux = catalog["truth_instFlux"]
251 if width == self.psf_size:
252 # When the filter matches the PSF, we should get close to the true flux.
253 tol = np.sqrt((filter_err/filter_flux)**2. + 0.02**2.)
254 self.assertFloatsAlmostEqual(filter_flux, truth_flux, rtol=tol)
255 elif width > self.psf_size:
256 # When the filter is larger than the PSF, the filter flux will be
257 # greater than the truth flux.
258 np.testing.assert_array_less(truth_flux, filter_flux)
260 if dc_offset == 0.0:
261 # Use the no-offset run as a comparison for offset runs.
262 flux_0 = filter_flux
263 else:
264 # Note: this tolerance is determined empirically, but this is
265 # larger than preferable.
266 self.assertFloatsAlmostEqual(filter_flux, flux_0, rtol=5e-3)
268 # The ratio of the filter flux to the truth flux should be consistent.
269 # I'm not sure how to scale this with the error, so this is a loose
270 # tolerance now.
271 ratio = filter_flux / truth_flux
272 self.assertLess(np.std(ratio), 0.04)
274 def testMonteCarlo(self):
275 """Test an ideal simulation, with no noise.
277 Demonstrate that:
279 - We get exactly the right answer, and
280 - The reported uncertainty agrees with a Monte Carlo test of the noise.
281 """
282 nSamples = 500
284 for width in self.larger_widths:
285 for t in self.all_ts:
286 config = lsst.meas.base.SingleFrameCompensatedGaussianFluxConfig()
287 config.kernel_widths = [width]
288 config.t = t
290 algorithm, schema = self.makeAlgorithm(config=config)
292 # Make a noiseless catalog.
293 exposure, catalog = self.dataset_single.realize(1E-8, schema, randomSeed=1)
295 # Only use the high-flux source for the error tests.
296 record = catalog[0]
297 algorithm.measure(record, exposure)
298 inst_flux = record[f"base_CompensatedGaussianFlux_{width}_instFlux"]
300 for noise in (0.001, 0.01, 0.1):
301 fluxes = np.zeros(nSamples)
302 errs = np.zeros_like(fluxes)
304 for repeat in range(nSamples):
305 # By using ``repeat`` to seed the RNG, we get results which
306 # fall within the tolerances defined below. If we allow this
307 # test to be truly random, passing becomes RNG-dependent.
308 exposure_samp, catalog_samp = self.dataset_single.realize(
309 noise*inst_flux,
310 schema,
311 randomSeed=repeat,
312 )
313 record_samp = catalog_samp[0]
314 algorithm.measure(record_samp, exposure_samp)
315 fluxes[repeat] = record_samp[f"base_CompensatedGaussianFlux_{width}_instFlux"]
316 errs[repeat] = record_samp[f"base_CompensatedGaussianFlux_{width}_instFluxErr"]
318 err_mean = np.mean(errs)
319 flux_std = np.std(fluxes)
320 self.assertFloatsAlmostEqual(err_mean, flux_std, rtol=0.10)
323class TestMemory(lsst.utils.tests.MemoryTestCase):
324 pass
327def setup_module(module):
328 lsst.utils.tests.init()
331if __name__ == "__main__": 331 ↛ 332line 331 didn't jump to line 332, because the condition on line 331 was never true
332 lsst.utils.tests.init()
333 unittest.main()