Coverage for tests/test_coadds.py: 26%
200 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-01 13:29 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-01 13:29 +0000
1# This file is part of cell_coadds.
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
23from collections.abc import Iterable, Mapping
25import lsst.cell_coadds.test_utils as test_utils
26import lsst.geom as geom
27import lsst.meas.base.tests
28import lsst.utils.tests
29import numpy as np
30from lsst.afw.geom import Quadrupole
31from lsst.afw.image import ExposureF, ImageF
32from lsst.cell_coadds import (
33 CellCoaddFitsReader,
34 CellIdentifiers,
35 CoaddUnits,
36 CommonComponents,
37 ExplodedCoadd,
38 MultipleCellCoadd,
39 OwnedImagePlanes,
40 PatchIdentifiers,
41 SingleCellCoadd,
42 StitchedCoadd,
43 UniformGrid,
44)
45from lsst.meas.algorithms import SingleGaussianPsf
46from lsst.skymap import Index2D
49class BaseMultipleCellCoaddTestCase(lsst.utils.tests.TestCase):
50 """A base class that provides a common set of methods."""
52 psf_size: int
53 psf_sigmas: Mapping[Index2D, float]
54 border_size: int
55 inner_size: int
56 outer_size: int
57 test_positions: Iterable[tuple[geom.Point2D, Index2D]]
58 exposures: Mapping[Index2D, lsst.afw.image.ExposureF]
59 multiple_cell_coadd: MultipleCellCoadd
61 @classmethod
62 def setUpClass(cls) -> None:
63 """Set up a multiple cell coadd with 2x2 cells."""
64 np.random.seed(42)
65 data_id = test_utils.generate_data_id()
66 common = CommonComponents(
67 units=CoaddUnits.legacy, # units here are arbitrary.
68 wcs=test_utils.generate_wcs(),
69 band=data_id["band"],
70 identifiers=PatchIdentifiers.from_data_id(data_id),
71 )
73 cls.nx, cls.ny = 3, 2
74 cls.psf_sigmas = {
75 Index2D(x=0, y=0): 1.2,
76 Index2D(x=0, y=1): 0.7,
77 Index2D(x=1, y=0): 0.9,
78 Index2D(x=1, y=1): 1.1,
79 Index2D(x=2, y=0): 1.3,
80 Index2D(x=2, y=1): 0.8,
81 }
83 cls.border_size = 5
84 # In practice, we expect this to be squares.
85 # To check for any possible x, y swap, we use different values.
86 cls.psf_size_x, cls.psf_size_y = 21, 23
87 cls.inner_size_x, cls.inner_size_y = 17, 15
88 cls.outer_size_x = cls.inner_size_x + 2 * cls.border_size
89 cls.outer_size_y = cls.inner_size_y + 2 * cls.border_size
90 # The origin should not be at (0, 0) for robust testing.
91 cls.x0, cls.y0 = 5, 2
93 patch_outer_bbox = geom.Box2I(
94 geom.Point2I(cls.x0, cls.y0), geom.Extent2I(cls.nx * cls.inner_size_x, cls.ny * cls.inner_size_y)
95 )
96 patch_outer_bbox.grow(cls.border_size)
98 # Add one star and one galaxy per quadrant.
99 # The mapping of positions to cell indices assume inner_size = (17, 15)
100 # and border_size = 5. If that is changed, these values need an update.
101 sources = (
102 # flux, centroid, shape
103 (1000.0, geom.Point2D(cls.x0 + 6.3, cls.y0 + 7.2), None),
104 (2500.0, geom.Point2D(cls.x0 + 16.8, cls.y0 + 18.3), None),
105 (1500.0, geom.Point2D(cls.x0 + 21.2, cls.y0 + 5.1), None),
106 (3200.0, geom.Point2D(cls.x0 + 16.1, cls.y0 + 23.9), None),
107 (1800.0, geom.Point2D(cls.x0 + 44.7, cls.y0 + 8.9), None),
108 (2100.0, geom.Point2D(cls.x0 + 34.1, cls.y0 + 19.2), None),
109 (900.0, geom.Point2D(cls.x0 + 9.1, cls.y0 + 13.9), Quadrupole(2.5, 1.5, 0.8)),
110 (1250.0, geom.Point2D(cls.x0 + 19.3, cls.y0 + 11.2), Quadrupole(1.5, 2.5, 0.75)),
111 (2100.0, geom.Point2D(cls.x0 + 5.1, cls.y0 + 21.2), Quadrupole(1.7, 1.9, 0.05)),
112 (2800.0, geom.Point2D(cls.x0 + 24.1, cls.y0 + 19.2), Quadrupole(1.9, 1.7, 0.1)),
113 (2350.0, geom.Point2D(cls.x0 + 40.3, cls.y0 + 13.9), Quadrupole(1.8, 1.8, -0.4)),
114 (4999.0, geom.Point2D(cls.x0 + 45.8, cls.y0 + 22.0), Quadrupole(1.6, 1.2, 0.2)),
115 )
117 # The test points are chosen to cover various corner cases assuming
118 # inner_size = (17, 15) and border_size = 5. If that is changed, the
119 # test points should be updated to not fall outside the coadd and still
120 # cover the description in the inline comments.
121 test_points = (
122 geom.Point2D(cls.x0 + 5, cls.y0 + 4), # inner point in lower left
123 geom.Point2D(cls.x0 + 6, cls.y0 + 24), # inner point in upper left
124 geom.Point2D(cls.x0 + 25.2, cls.y0 + 7.8), # inner point in lower middle
125 geom.Point2D(cls.x0 + 23, cls.y0 + 22), # inner point in upper middle
126 geom.Point2D(cls.x0 + 39, cls.y0 + 9.4), # inner point in lower right
127 geom.Point2D(cls.x0 + 44, cls.y0 + 24), # inner point in upper right
128 # Some points that lie on the border
129 geom.Point2D(cls.x0 + 33, cls.y0 + 24), # inner point in upper right
130 geom.Point2D(cls.x0 + 46, cls.y0 + 0), # inner point in lower right
131 geom.Point2D(cls.x0 + 19, cls.y0 + 16), # inner point in upper middle
132 geom.Point2D(cls.x0 + 17, cls.y0 + 8), # inner point in lower middle
133 geom.Point2D(cls.x0 + 0, cls.y0 + 29), # inner point in upper left
134 geom.Point2D(cls.x0 + 0, cls.y0 + 0), # inner point in lower left
135 )
136 # A tuple of (point, cell_index) pairs.
137 cls.test_positions = (
138 (
139 point,
140 Index2D(
141 x=int((point.getX() - cls.x0) // cls.inner_size_x),
142 y=int((point.getY() - cls.y0) // cls.inner_size_y),
143 ),
144 )
145 for point in test_points
146 )
148 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema()
150 single_cell_coadds = []
151 cls.exposures = dict.fromkeys(cls.psf_sigmas.keys())
153 for x in range(cls.nx):
154 for y in range(cls.ny):
155 identifiers = CellIdentifiers(
156 cell=Index2D(x=x, y=y),
157 skymap=common.identifiers.skymap,
158 tract=common.identifiers.tract,
159 patch=common.identifiers.patch,
160 band=common.identifiers.band,
161 )
163 outer_bbox = geom.Box2I(
164 geom.Point2I(cls.x0 + x * cls.inner_size_x, cls.y0 + y * cls.inner_size_y),
165 geom.Extent2I(cls.inner_size_x, cls.inner_size_y),
166 )
167 outer_bbox.grow(cls.border_size)
169 dataset = lsst.meas.base.tests.TestDataset(
170 patch_outer_bbox, psfSigma=cls.psf_sigmas[identifiers.cell]
171 )
173 for inst_flux, position, shape in sources:
174 dataset.addSource(inst_flux, position, shape)
176 # Create a spatially varying variance plane.
177 variance = ImageF(
178 # np.random.uniform returns an array with x-y flipped.
179 np.random.uniform(
180 0.8,
181 1.2,
182 (
183 cls.ny * cls.inner_size_y + 2 * cls.border_size,
184 cls.nx * cls.inner_size_x + 2 * cls.border_size,
185 ),
186 ).astype(np.float32),
187 xy0=outer_bbox.getMin(),
188 )
189 exposure, _ = dataset.realize(variance.getArray() ** 0.5, schema, randomSeed=123456789)
190 cls.exposures[identifiers.cell] = exposure
191 exposure = exposure[outer_bbox]
192 image_plane = OwnedImagePlanes(
193 image=exposure.image, variance=exposure.variance, mask=exposure.mask
194 )
196 single_cell_coadds.append(
197 SingleCellCoadd(
198 outer=image_plane,
199 psf=SingleGaussianPsf(
200 cls.psf_size_x, cls.psf_size_y, cls.psf_sigmas[Index2D(x=x, y=y)]
201 ).computeKernelImage(outer_bbox.getCenter()),
202 inner_bbox=geom.Box2I(
203 geom.Point2I(cls.x0 + x * cls.inner_size_x, cls.y0 + y * cls.inner_size_y),
204 geom.Extent2I(cls.inner_size_x, cls.inner_size_y),
205 ),
206 inputs={
207 None, # type: ignore [arg-type]
208 },
209 common=common,
210 identifiers=identifiers,
211 )
212 )
214 grid_bbox = geom.Box2I(
215 geom.Point2I(cls.x0, cls.y0), geom.Extent2I(cls.nx * cls.inner_size_x, cls.ny * cls.inner_size_y)
216 )
217 grid = UniformGrid.from_bbox_shape(grid_bbox, Index2D(x=cls.nx, y=cls.ny))
219 cls.multiple_cell_coadd = MultipleCellCoadd(
220 single_cell_coadds,
221 grid=grid,
222 outer_cell_size=geom.Extent2I(cls.outer_size_x, cls.outer_size_y),
223 inner_bbox=None,
224 common=common,
225 psf_image_size=geom.Extent2I(cls.psf_size_x, cls.psf_size_y),
226 )
228 @classmethod
229 def tearDownClass(cls) -> None: # noqa: D102
230 # Docstring inherited
231 del cls.multiple_cell_coadd
232 del cls.exposures
233 super().tearDownClass()
235 def assertMultipleCellCoaddsEqual(self, mcc1: MultipleCellCoadd, mcc2: MultipleCellCoadd) -> None:
236 """Check the equality of two instances of `MultipleCellCoadd`.
238 Parameters
239 ----------
240 mcc1 : `MultipleCellCoadd`
241 The MultipleCellCoadd created by reading a FITS file.
242 mcc2 : `MultipleCellCoadd`
243 The reference MultipleCellCoadd for comparison.
244 """
245 self.assertEqual(mcc1.band, mcc2.band)
246 self.assertEqual(mcc1.identifiers, mcc2.identifiers)
247 self.assertEqual(mcc1.inner_bbox, mcc2.inner_bbox)
248 self.assertEqual(mcc1.outer_bbox, mcc2.outer_bbox)
249 self.assertEqual(mcc1.outer_cell_size, mcc2.outer_cell_size)
250 self.assertEqual(mcc1.mask_fraction_names, mcc2.mask_fraction_names)
251 self.assertEqual(mcc1.n_noise_realizations, mcc2.n_noise_realizations)
252 self.assertEqual(mcc1.psf_image_size, mcc2.psf_image_size)
253 self.assertEqual(mcc1.units, mcc2.units)
254 self.assertEqual(mcc1.wcs.getFitsMetadata().toString(), mcc2.wcs.getFitsMetadata().toString())
256 # Check that the individual cells are identical.
257 self.assertEqual(mcc1.cells.keys(), mcc2.cells.keys())
258 for idx in mcc1.cells.keys(): # noqa: SIM118
259 self.assertImagesEqual(mcc1.cells[idx].outer.image, mcc2.cells[idx].outer.image)
260 self.assertMasksEqual(mcc1.cells[idx].outer.mask, mcc2.cells[idx].outer.mask)
261 self.assertImagesEqual(mcc1.cells[idx].outer.variance, mcc2.cells[idx].outer.variance)
262 self.assertImagesEqual(mcc1.cells[idx].psf_image, mcc2.cells[idx].psf_image)
264 self.assertEqual(mcc1.cells[idx].band, mcc1.band)
265 self.assertEqual(mcc1.cells[idx].common, mcc1.common)
266 self.assertEqual(mcc1.cells[idx].units, mcc2.units)
267 self.assertEqual(mcc1.cells[idx].wcs, mcc1.wcs)
268 # Identifiers differ because of the ``cell`` component.
269 # Check the other attributes within the identifiers.
270 for attr in ("skymap", "tract", "patch", "band"):
271 self.assertEqual(getattr(mcc1.cells[idx].identifiers, attr), getattr(mcc1.identifiers, attr))
274class MultipleCellCoaddTestCase(BaseMultipleCellCoaddTestCase):
275 """Test the construction and interfaces of MultipleCellCoadd."""
277 def test_fits(self):
278 """Test that we can write a coadd to a FITS file and read it."""
279 with lsst.utils.tests.getTempFilePath(".fits") as filename:
280 self.multiple_cell_coadd.write_fits(filename)
281 mcc1 = MultipleCellCoadd.read_fits(filename) # Test the readFits method.
283 # Test the reader class.
284 reader = CellCoaddFitsReader(filename)
285 mcc2 = reader.readAsMultipleCellCoadd()
287 wcs = reader.readWcs()
289 self.assertMultipleCellCoaddsEqual(mcc1, self.multiple_cell_coadd)
290 self.assertMultipleCellCoaddsEqual(mcc2, self.multiple_cell_coadd)
291 # By transititve property of equality, mcc1 == mcc2.
293 self.assertEqual(self.multiple_cell_coadd.band, self.multiple_cell_coadd.common.band)
294 self.assertEqual(
295 wcs.getFitsMetadata().toString(), self.multiple_cell_coadd.wcs.getFitsMetadata().toString()
296 )
299class ExplodedCoaddTestCase(BaseMultipleCellCoaddTestCase):
300 """Test the construction and methods of an ExplodedCoadd instance."""
302 exploded_coadd: ExplodedCoadd
304 @classmethod
305 def setUpClass(cls) -> None: # noqa: D102
306 # Docstring inherited
307 super().setUpClass()
308 cls.exploded_coadd = cls.multiple_cell_coadd.explode()
310 @classmethod
311 def tearDownClass(cls) -> None: # noqa: D102
312 # Docstring inherited
313 del cls.exploded_coadd
314 super().tearDownClass()
316 def test_exploded_psf_image(self):
317 """Show that psf_image sizes are absurd."""
318 self.assertEqual(
319 self.exploded_coadd.psf_image.getBBox().getDimensions(),
320 geom.Extent2I(self.nx * self.psf_size_x, self.ny * self.psf_size_y),
321 )
322 for pad_psfs_with in (-999, -4, 0, 4, 8, 21, 40, 100):
323 exploded_coadd = self.multiple_cell_coadd.explode(pad_psfs_with=pad_psfs_with)
324 self.assertEqual(
325 exploded_coadd.psf_image.getBBox().getDimensions(),
326 geom.Extent2I(self.nx * self.outer_size_x, self.ny * self.outer_size_y),
327 )
330class StitchedCoaddTestCase(BaseMultipleCellCoaddTestCase):
331 """Test the construction and methods of a StitchedCoadd instance."""
333 stitched_coadd: StitchedCoadd
335 @classmethod
336 def setUpClass(cls) -> None: # noqa: D102
337 # Docstring inherited
338 super().setUpClass()
339 cls.stitched_coadd = cls.multiple_cell_coadd.stitch()
341 @classmethod
342 def tearDownClass(cls) -> None: # noqa: D102
343 # Docstring inherited
344 del cls.stitched_coadd
345 super().tearDownClass()
347 def test_computeBBox(self):
348 """Test the computeBBox method for a StitchedPsf object."""
349 stitched_psf = self.stitched_coadd.psf
351 psf_bbox = geom.Box2I(
352 geom.Point2I(-(self.psf_size_x // 2), -(self.psf_size_y // 2)),
353 geom.Extent2I(self.psf_size_x, self.psf_size_y),
354 )
356 for position, _ in self.test_positions:
357 bbox = stitched_psf.computeBBox(position)
358 self.assertEqual(bbox, psf_bbox)
360 def test_computeShape(self):
361 """Test the computeShape method for a StitchedPsf object."""
362 stitched_psf = self.stitched_coadd.psf
363 for position, cell_index in self.test_positions:
364 psf_shape = stitched_psf.computeShape(position) # check we can compute shape
365 self.assertIsNot(psf_shape.getIxx(), np.nan)
366 self.assertIsNot(psf_shape.getIyy(), np.nan)
367 self.assertIsNot(psf_shape.getIxy(), np.nan)
369 # Moments measured from pixellated images are significantly
370 # underestimated for small PSFs.
371 if self.psf_sigmas[cell_index] >= 1.0:
372 self.assertAlmostEqual(psf_shape.getIxx(), self.psf_sigmas[cell_index] ** 2, delta=1e-3)
373 self.assertAlmostEqual(psf_shape.getIyy(), self.psf_sigmas[cell_index] ** 2, delta=1e-3)
374 self.assertAlmostEqual(psf_shape.getIxy(), 0.0)
376 def test_computeKernelImage(self):
377 """Test the computeKernelImage method for a StitchedPsf object."""
378 stitched_psf = self.stitched_coadd.psf
379 psf_bbox = geom.Box2I(
380 geom.Point2I(-(self.psf_size_x // 2), -(self.psf_size_y // 2)),
381 geom.Extent2I(self.psf_size_x, self.psf_size_y),
382 )
384 for position, cell_index in self.test_positions:
385 image1 = stitched_psf.computeKernelImage(position)
386 image2 = SingleGaussianPsf(
387 self.psf_size_x, self.psf_size_y, self.psf_sigmas[cell_index]
388 ).computeKernelImage(position)
389 self.assertImagesEqual(image1, image2)
390 self.assertEqual(image1.getBBox(), psf_bbox)
392 def test_computeImage(self):
393 """Test the computeImage method for a StitchedPsf object."""
394 stitched_psf = self.stitched_coadd.psf
395 psf_extent = geom.Extent2I(self.psf_size_x, self.psf_size_y)
397 for position, cell_index in self.test_positions:
398 image1 = stitched_psf.computeImage(position)
399 image2 = SingleGaussianPsf(
400 self.psf_size_x, self.psf_size_y, self.psf_sigmas[cell_index]
401 ).computeImage(position)
402 self.assertImagesEqual(image1, image2)
403 self.assertEqual(image1.getBBox().getDimensions(), psf_extent)
405 def test_computeImage_computeKernelImage(self):
406 """Test that computeImage called at integer points gives the same
407 result as calling computeKernelImage.
408 """
409 stitched_psf = self.stitched_coadd.psf
410 for position, _cell_index in self.test_positions:
411 pos = geom.Point2D(geom.Point2I(position)) # round to integer
412 image1 = stitched_psf.computeKernelImage(pos)
413 image2 = stitched_psf.computeImage(pos)
414 self.assertImagesEqual(image1, image2)
416 def test_computeApetureFlux(self):
417 """Test the computeApertureFlux method for a StitchedPsf object."""
418 stitched_psf = self.stitched_coadd.psf
419 for position, cell_index in self.test_positions:
420 flux1sigma = stitched_psf.computeApertureFlux(self.psf_sigmas[cell_index], position=position)
421 self.assertAlmostEqual(flux1sigma, 0.39, delta=5e-2)
423 flux3sigma = stitched_psf.computeApertureFlux(
424 3.0 * self.psf_sigmas[cell_index], position=position
425 )
426 self.assertAlmostEqual(flux3sigma, 0.97, delta=2e-2)
428 def test_asExposure(self):
429 """Test the asExposure method for a StitchedCoadd object."""
430 exposure = self.stitched_coadd.asExposure()
432 # Check that the bounding box is correct.
433 bbox = exposure.getBBox()
434 self.assertEqual(bbox.getWidth(), self.inner_size_x * self.nx + 2 * self.border_size)
435 self.assertEqual(bbox.getHeight(), self.inner_size_y * self.ny + 2 * self.border_size)
437 for y in range(self.ny):
438 for x in range(self.nx):
439 bbox = geom.Box2I(
440 geom.Point2I(self.x0 + x * self.inner_size_x, self.y0 + y * self.inner_size_y),
441 geom.Extent2I(self.inner_size_x, self.inner_size_y),
442 )
443 index = Index2D(x=x, y=y)
444 self.assertImagesEqual(exposure.image[bbox], self.exposures[index].image[bbox])
445 self.assertImagesEqual(exposure.variance[bbox], self.exposures[index].variance[bbox])
446 self.assertImagesEqual(exposure.mask[bbox], self.exposures[index].mask[bbox])
448 def test_fits(self):
449 """Test that we can write an Exposure with StitchedPsf to a FITS file
450 and read it.
451 """
452 write_exposure = self.stitched_coadd.asExposure()
453 with lsst.utils.tests.getTempFilePath(".fits") as filename:
454 write_exposure.writeFits(filename)
455 read_exposure = ExposureF.readFits(filename) # Test the readFits method.
457 # Test that the image planes are identical.
458 self.assertImagesEqual(read_exposure.image, write_exposure.image)
459 self.assertImagesEqual(read_exposure.variance, write_exposure.variance)
460 self.assertImagesEqual(read_exposure.mask, write_exposure.mask)
462 # Test the PSF images in the StitchedPsf.
463 for index in write_exposure.psf.images.indices():
464 self.assertImagesEqual(read_exposure.psf.images[index], write_exposure.psf.images[index])
466 # Test that the WCSs are equal.
467 self.assertEqual(
468 read_exposure.wcs.getFitsMetadata().toString(),
469 write_exposure.wcs.getFitsMetadata().toString(),
470 )
473class TestMemory(lsst.utils.tests.MemoryTestCase):
474 """Check for resource/memory leaks."""
477def setup_module(module): # noqa: D103
478 lsst.utils.tests.init()
481if __name__ == "__main__": 481 ↛ 482line 481 didn't jump to line 482, because the condition on line 481 was never true
482 lsst.utils.tests.init()
483 unittest.main()