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