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