Coverage for python / lsst / images / tests / _checks.py: 11%
364 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-14 01:14 -0700
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-14 01:14 -0700
1# This file is part of lsst-images.
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# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14__all__ = (
15 "arrays_to_legacy_points",
16 "assert_close",
17 "assert_equal_allow_nan",
18 "assert_images_equal",
19 "assert_masked_images_equal",
20 "assert_masks_equal",
21 "assert_projections_equal",
22 "assert_psfs_equal",
23 "check_astropy_wcs_interface",
24 "check_projection",
25 "check_transform",
26 "compare_amplifier_to_legacy",
27 "compare_aperture_corrections_to_legacy",
28 "compare_cell_coadd_to_legacy",
29 "compare_detector_to_legacy",
30 "compare_field_to_legacy",
31 "compare_image_to_legacy",
32 "compare_mask_to_legacy",
33 "compare_masked_image_to_legacy",
34 "compare_observation_summary_stats_to_legacy",
35 "compare_projection_to_legacy_wcs",
36 "compare_psf_to_legacy",
37 "compare_visit_image_to_legacy",
38 "legacy_coords_to_astropy",
39 "legacy_points_to_xy_array",
40)
42import dataclasses
43import math
44import unittest
45from collections.abc import Mapping
46from typing import TYPE_CHECKING, Any, Literal, cast
48import astropy.units as u
49import astropy.wcs.wcsapi
50import numpy as np
51from astropy.coordinates import SkyCoord
53from .._geom import XY, YX, BoundsError, Box
54from .._image import Image
55from .._mask import Mask, MaskPlane
56from .._masked_image import MaskedImage
57from .._observation_summary_stats import ObservationSummaryStats
58from .._transforms import DetectorFrame, Frame, Projection, SkyFrame, TractFrame, Transform
59from .._visit_image import VisitImage
60from ..aperture_corrections import ApertureCorrectionMap
61from ..cameras import Amplifier, Detector, DetectorType, ReadoutCorner
62from ..cells import CellCoadd, CellIJ, CoaddProvenance
63from ..fields import BaseField
64from ..psfs import PointSpreadFunction
66if TYPE_CHECKING:
67 try:
68 from lsst.cell_coadds import MultipleCellCoadd
69 except ImportError:
70 type MultipleCellCoadd = Any # type: ignore[no-redef]
73def assert_close(
74 tc: unittest.TestCase,
75 a: np.ndarray | u.Quantity | float,
76 b: np.ndarray | u.Quantity | float,
77 **kwargs: Any,
78) -> None:
79 """Test that two arrays, floats, or quantities are almost equal.
81 Parameters
82 ----------
83 tc
84 Test case object with assert methods to use.
85 a
86 Array, float, or quantity to compare.
87 b
88 Array, float, or quantity to compare.
89 **kwargs
90 Forwarded to `astropy.units.allclose`.
91 """
92 tc.assertTrue(u.allclose(a, b, **kwargs), msg=f"{a} != {b}")
95def assert_equal_allow_nan(tc: unittest.TestCase, a: float, b: float) -> None:
96 """Test that two floating point values are equal, with nan == nan."""
97 try:
98 tc.assertEqual(a, b)
99 except AssertionError:
100 if not (math.isnan(a) and math.isnan(b)):
101 raise
104def assert_images_equal(
105 tc: unittest.TestCase,
106 a: Image,
107 b: Image,
108 *,
109 rtol: float = 0.0,
110 atol: float = 0.0,
111 expect_view: bool | Literal["array"] | None = None,
112) -> None:
113 """Assert that two images are equal or nearly equal."""
114 tc.assertEqual(a.bbox, b.bbox)
115 tc.assertEqual(a.unit, b.unit)
116 assert_projections_equal(tc, a.projection, b.projection)
117 if expect_view is not None:
118 tc.assertEqual(np.may_share_memory(a.array, b.array), bool(expect_view))
119 if expect_view == "array":
120 tc.assertEqual(a.metadata, b.metadata)
121 else:
122 tc.assertEqual(a.metadata is b.metadata, expect_view)
123 if not expect_view:
124 assert_close(tc, a.array, b.array, atol=atol, rtol=rtol)
125 tc.assertEqual(a.metadata, b.metadata)
128def assert_masks_equal(tc: unittest.TestCase, a: Mask, b: Mask) -> None:
129 """Assert that two masks are equal or nearly equal."""
130 tc.assertEqual(a.bbox, b.bbox)
131 tc.assertEqual(a.schema, b.schema)
132 tc.assertEqual(a.metadata, b.metadata)
133 assert_projections_equal(tc, a.projection, b.projection)
134 np.testing.assert_array_equal(a.array, b.array)
137def assert_masked_images_equal(
138 tc: unittest.TestCase,
139 a: MaskedImage,
140 b: MaskedImage,
141 *,
142 rtol: float = 0.0,
143 atol: float = 0.0,
144 expect_view: bool | None = None,
145) -> None:
146 """Assert that two masked images are equal or nearly equal."""
147 tc.assertEqual(a.metadata, b.metadata)
148 assert_projections_equal(tc, a.projection, b.projection)
149 assert_images_equal(tc, a.image, b.image, rtol=rtol, atol=atol, expect_view=expect_view)
150 assert_masks_equal(tc, a.mask, b.mask)
151 assert_images_equal(tc, a.variance, b.variance, rtol=rtol, atol=atol, expect_view=expect_view)
154def assert_psfs_equal(
155 tc: unittest.TestCase,
156 psf1: PointSpreadFunction,
157 psf2: PointSpreadFunction,
158 points: YX[np.ndarray] | XY[np.ndarray] | None = None,
159) -> int:
160 """Compare two PSF objets.
162 Parameters
163 ----------
164 tc
165 Test case object with assert methods to use.
166 psf1
167 Point-spread function to test.
168 psf2
169 The other point-spread function to test.
170 points
171 Points to evaluate the PSFs at. If not provided, the intersection of
172 the PSF bounding boxes are used to generate points on a grid.
174 Returns
175 -------
176 `int`
177 The number of points actually tested.
178 """
179 if points is None:
180 points = psf1.bounds.bbox.intersection(psf1.bounds.bbox).meshgrid(3).map(np.ravel)
182 tc.assertEqual(psf1.kernel_bbox, psf2.kernel_bbox)
184 n_points_tested: int = 0
185 for x, y in zip(points.x, points.y):
186 if not psf1.bounds.contains(x=x, y=y):
187 with tc.assertRaises(BoundsError):
188 psf2.compute_kernel_image(x=x, y=y)
189 continue
190 tc.assertEqual(psf1.compute_kernel_image(x=x, y=y), psf2.compute_kernel_image(x=x, y=y))
191 tc.assertEqual(psf1.compute_stellar_bbox(x=x, y=y), psf2.compute_stellar_bbox(x=x, y=y))
192 tc.assertEqual(psf1.compute_stellar_image(x=x, y=y), psf2.compute_stellar_image(x=x, y=y))
193 n_points_tested += 1
194 return n_points_tested
197def compare_image_to_legacy(
198 tc: unittest.TestCase, image: Image, legacy_image: Any, expect_view: bool | None = None
199) -> None:
200 """Compare an `.Image` object to a legacy `lsst.afw.image.Image` object."""
201 tc.assertEqual(image.bbox, Box.from_legacy(legacy_image.getBBox()))
202 if expect_view is not None:
203 tc.assertEqual(np.may_share_memory(image.array, legacy_image.array), expect_view)
204 if not expect_view:
205 np.testing.assert_array_equal(image.array, legacy_image.array)
208def compare_mask_to_legacy(
209 tc: unittest.TestCase, mask: Mask, legacy_mask: Any, plane_map: Mapping[str, MaskPlane] | None = None
210) -> None:
211 """Compare a `.Mask` object to a legacy `lsst.afw.image.Mask` object."""
212 tc.assertEqual(mask.bbox, Box.from_legacy(legacy_mask.getBBox()))
213 if plane_map is None:
214 plane_map = {plane.name: plane for plane in mask.schema if plane is not None}
215 for old_name, new_plane in plane_map.items():
216 np.testing.assert_array_equal(
217 (legacy_mask.array & legacy_mask.getPlaneBitMask(old_name)).astype(bool),
218 mask.get(new_plane.name),
219 )
222def compare_masked_image_to_legacy(
223 tc: unittest.TestCase,
224 masked_image: MaskedImage,
225 legacy_masked_image: Any,
226 *,
227 plane_map: Mapping[str, MaskPlane] | None = None,
228 expect_view: bool | None = None,
229 alternates: Mapping[str, Any] | None = None,
230) -> None:
231 """Compare a `.MaskedImage` object to a legacy `lsst.afw.image.MaskedImage`
232 object.
234 Parameters
235 ----------
236 tc
237 Test case to use for asserts.
238 masked_image
239 New image to test.
240 legacy_masked_image
241 Legacy image to test against.
242 plane_map
243 Mapping between new and legacy mask planes.
244 expect_view
245 Whether to test that the image and variance arrays do or do not share
246 memory.
247 alternates
248 A mapping of other versions of one or more (new) components to also
249 check against the legacy versions of those components.
250 """
251 compare_image_to_legacy(tc, masked_image.image, legacy_masked_image.getImage(), expect_view=expect_view)
252 compare_mask_to_legacy(tc, masked_image.mask, legacy_masked_image.getMask(), plane_map=plane_map)
253 compare_image_to_legacy(
254 tc, masked_image.variance, legacy_masked_image.getVariance(), expect_view=expect_view
255 )
256 if alternates:
257 if image := alternates.get("image"):
258 compare_image_to_legacy(tc, image, legacy_masked_image.getImage(), expect_view=expect_view)
259 if mask := alternates.get("mask"):
260 compare_mask_to_legacy(tc, mask, legacy_masked_image.getMask(), plane_map=plane_map)
261 if variance := alternates.get("variance"):
262 compare_image_to_legacy(tc, variance, legacy_masked_image.getVariance(), expect_view=expect_view)
265def compare_visit_image_to_legacy(
266 tc: unittest.TestCase,
267 visit_image: VisitImage,
268 legacy_exposure: Any,
269 *,
270 plane_map: Mapping[str, MaskPlane] | None = None,
271 expect_view: bool | None = None,
272 instrument: str,
273 visit: int,
274 detector: int,
275 alternates: Mapping[str, Any] | None = None,
276) -> None:
277 """Compare a `.VisitImage` object to a legacy `lsst.afw.image.Exposure`
278 object.
280 Parameters
281 ----------
282 tc
283 Test case to use for asserts.
284 visit_image
285 New image to test.
286 legacy_exposure
287 Legacy image to test against.
288 plane_map
289 Mapping between new and legacy mask planes.
290 expect_view
291 Whether to test that the image and variance arrays do or do not share
292 memory.
293 instrument
294 Expected instrument name.
295 visit
296 Expected visit ID.
297 detector
298 Expected detector ID.
299 alternates
300 A mapping of other versions of one or more (new) components to also
301 check against the legacy versions of those components.
302 """
303 compare_masked_image_to_legacy(
304 tc,
305 visit_image,
306 legacy_exposure.getMaskedImage(),
307 plane_map=plane_map,
308 expect_view=expect_view,
309 alternates=alternates,
310 )
311 detector_bbox = Box.from_legacy(legacy_exposure.getDetector().getBBox())
312 compare_projection_to_legacy_wcs(
313 tc,
314 visit_image.projection,
315 legacy_exposure.getWcs(),
316 DetectorFrame(instrument=instrument, visit=visit, detector=detector, bbox=detector_bbox),
317 visit_image.bbox,
318 )
319 tc.assertIs(visit_image.projection, visit_image.mask.projection)
320 tc.assertIs(visit_image.projection, visit_image.variance.projection)
321 compare_psf_to_legacy(tc, visit_image.psf, legacy_exposure.getPsf())
322 compare_observation_summary_stats_to_legacy(
323 tc, visit_image.summary_stats, legacy_exposure.info.getSummaryStats()
324 )
325 compare_detector_to_legacy(tc, visit_image.detector, legacy_exposure.getDetector(), is_raw_assembled=True)
326 # Make a tiny box for Field comparisons that need to make arrays; that can
327 # get expensive otherwisre.
328 tiny_bbox = detector_bbox.local[2:4, 3:6]
329 compare_aperture_corrections_to_legacy(
330 tc, visit_image.aperture_corrections, legacy_exposure.info.getApCorrMap(), tiny_bbox
331 )
332 if alternates:
333 if projection := alternates.get("projection"):
334 compare_projection_to_legacy_wcs(
335 tc,
336 projection,
337 legacy_exposure.getWcs(),
338 DetectorFrame(instrument=instrument, visit=visit, detector=detector, bbox=detector_bbox),
339 visit_image.bbox,
340 )
341 if psf := alternates.get("psf"):
342 compare_psf_to_legacy(tc, psf, legacy_exposure.getPsf())
343 if summary_stats := alternates.get("summary_stats"):
344 compare_observation_summary_stats_to_legacy(
345 tc, summary_stats, legacy_exposure.info.getSummaryStats()
346 )
347 if detector_obj := alternates.get("detector"):
348 compare_detector_to_legacy(tc, detector_obj, legacy_exposure.getDetector(), is_raw_assembled=True)
349 if obs_info := alternates.get("obs_info"):
350 visitInfo = legacy_exposure.visitInfo
351 tc.assertEqual(obs_info.instrument, visitInfo.getInstrumentLabel())
352 if aperture_corrections := alternates.get("aperture_corrections"):
353 compare_aperture_corrections_to_legacy(
354 tc, aperture_corrections, legacy_exposure.info.getApCorrMap(), tiny_bbox
355 )
358def compare_cell_coadd_to_legacy(
359 tc: unittest.TestCase,
360 cell_coadd: CellCoadd,
361 legacy_cell_coadd: MultipleCellCoadd,
362 *,
363 tract_bbox: Box,
364 plane_map: Mapping[str, MaskPlane] | None = None,
365 alternates: Mapping[str, Any] | None = None,
366 psf_points: XY[np.ndarray] | YX[np.ndarray] | None = None,
367) -> None:
368 """Compare a `.cells.CellCoadd` object to a legacy
369 `lsst.cell_coadds.MultipleCellCoadd` object.
371 Parameters
372 ----------
373 tc
374 Test case to use for asserts.
375 cell_coadd
376 New coadd to test.
377 legacy_cell_coadd
378 Legacy coadd to test against.
379 tract_bbox
380 Bounding box of the full tract.
381 psf_points
382 Points to use to compare the PSFs.
383 plane_map
384 Mapping between new and legacy mask planes.
385 alternates
386 A mapping of other versions of one or more (new) components to also
387 check against the legacy versions of those components.
388 """
389 legacy_stitched = legacy_cell_coadd.stitch(cell_coadd.bbox.to_legacy())
390 compare_image_to_legacy(tc, cell_coadd.image, legacy_stitched.image, expect_view=False)
391 compare_mask_to_legacy(tc, cell_coadd.mask, legacy_stitched.mask, plane_map=plane_map)
392 compare_image_to_legacy(tc, cell_coadd.variance, legacy_stitched.variance, expect_view=False)
393 if legacy_stitched.mask_fractions is not None:
394 compare_image_to_legacy(
395 tc, cell_coadd.mask_fractions["rejected"], legacy_stitched.mask_fractions, expect_view=False
396 )
397 for n in range(legacy_stitched.n_noise_realizations):
398 compare_image_to_legacy(
399 tc, cell_coadd.noise_realizations[n], legacy_stitched.noise_realizations[n], expect_view=False
400 )
401 tc.assertEqual(cell_coadd.skymap, legacy_stitched.identifiers.skymap)
402 tc.assertEqual(cell_coadd.tract, legacy_stitched.identifiers.tract)
403 tc.assertEqual(cell_coadd.patch.index.x, legacy_stitched.identifiers.patch.x)
404 tc.assertEqual(cell_coadd.patch.index.y, legacy_stitched.identifiers.patch.y)
405 tc.assertEqual(cell_coadd.band, legacy_stitched.identifiers.band)
406 tc.assertTrue(tract_bbox.contains(cell_coadd.patch.outer_bbox))
407 tc.assertTrue(cell_coadd.patch.outer_bbox.contains(cell_coadd.patch.inner_bbox))
408 tc.assertTrue(cell_coadd.patch.outer_bbox.contains(cell_coadd.bbox))
409 tc.assertEqual(cell_coadd.unit, u.Unit(legacy_cell_coadd.common.units.value))
410 tc.assertTrue(cell_coadd.bounds.bbox.contains(cell_coadd.bbox))
411 tc.assertTrue(cell_coadd.grid.bbox.contains(cell_coadd.bbox))
412 compare_projection_to_legacy_wcs(
413 tc,
414 cell_coadd.projection,
415 legacy_cell_coadd.wcs,
416 TractFrame(
417 skymap=legacy_cell_coadd.identifiers.skymap,
418 tract=legacy_cell_coadd.identifiers.tract,
419 bbox=tract_bbox,
420 ),
421 cell_coadd.bbox,
422 is_fits=True,
423 )
424 tc.assertIs(cell_coadd.projection, cell_coadd.mask.projection)
425 tc.assertIs(cell_coadd.projection, cell_coadd.variance.projection)
426 compare_psf_to_legacy(
427 tc, cell_coadd.psf, legacy_stitched.psf, expect_legacy_raise_on_out_of_bounds=True, points=psf_points
428 )
429 compare_cell_coadd_provenance_to_legacy(tc, cell_coadd.provenance, legacy_cell_coadd)
430 if alternates:
431 if projection := alternates.get("projection"):
432 compare_projection_to_legacy_wcs(
433 tc,
434 projection,
435 legacy_stitched.wcs,
436 TractFrame(
437 skymap=legacy_cell_coadd.identifiers.skymap,
438 tract=legacy_cell_coadd.identifiers.tract,
439 bbox=tract_bbox,
440 ),
441 cell_coadd.bbox,
442 is_fits=True,
443 )
444 if psf := alternates.get("psf"):
445 compare_psf_to_legacy(tc, psf, legacy_stitched.psf, points=psf_points)
446 if provenance := alternates.get("provenance"):
447 compare_cell_coadd_provenance_to_legacy(tc, provenance, legacy_cell_coadd)
450def compare_cell_coadd_provenance_to_legacy(
451 tc: unittest.TestCase, provenance: CoaddProvenance, legacy_cell_coadd: MultipleCellCoadd
452) -> None:
453 """Compare a `.cells.CoaddProvenance` object to a legacy
454 `lsst.cell_coadds.MultipleCellCoadd` object.
456 Parameters
457 ----------
458 tc
459 Test case to use for asserts.
460 provenance
461 New provenance object to test.
462 legacy_cell_coadd
463 Legacy coadd to test against.
464 """
465 from lsst.cell_coadds import ObservationIdentifiers
467 for legacy_cell in legacy_cell_coadd.cells.values():
468 cell_index = CellIJ.from_legacy(legacy_cell.identifiers.cell)
469 prov = provenance[cell_index]
470 legacy_table = astropy.table.Table(
471 rows=[
472 [
473 ids.instrument,
474 ids.visit,
475 ids.detector,
476 ids.day_obs,
477 ids.physical_filter,
478 legacy_input.overlaps_center,
479 legacy_input.overlap_fraction,
480 legacy_input.weight,
481 legacy_input.psf_shape.getIxx(),
482 legacy_input.psf_shape.getIyy(),
483 legacy_input.psf_shape.getIxy(),
484 legacy_input.psf_shape_flag,
485 ]
486 for ids, legacy_input in legacy_cell.inputs.items()
487 ],
488 dtype=[
489 np.object_,
490 np.uint64,
491 np.uint16,
492 np.uint32,
493 np.object_,
494 np.bool_,
495 np.float64,
496 np.float64,
497 np.float64,
498 np.float64,
499 np.float64,
500 np.bool_,
501 ],
502 names=[
503 "instrument",
504 "visit",
505 "detector",
506 "day_obs",
507 "physical_filter",
508 "overlaps_center",
509 "overlap_fraction",
510 "weight",
511 "psf_shape_xx",
512 "psf_shape_yy",
513 "psf_shape_xy",
514 "psf_shape_flag",
515 ],
516 )
517 # For a single cell all 'inputs' are also 'contributions'.
518 tc.assertEqual(len(legacy_cell.inputs), len(prov.inputs))
519 tc.assertEqual(len(legacy_cell.inputs), len(prov.contributions))
520 prov.inputs.sort(["instrument", "visit", "detector"])
521 prov.contributions.sort(["instrument", "visit", "detector"])
522 legacy_table.sort(["instrument", "visit", "detector"])
523 np.testing.assert_array_equal(prov.inputs["instrument"], prov.contributions["instrument"])
524 np.testing.assert_array_equal(prov.inputs["visit"], prov.contributions["visit"])
525 np.testing.assert_array_equal(prov.inputs["detector"], prov.contributions["detector"])
526 np.testing.assert_array_equal(prov.inputs["instrument"], legacy_table["instrument"])
527 np.testing.assert_array_equal(prov.inputs["visit"], legacy_table["visit"])
528 np.testing.assert_array_equal(prov.inputs["detector"], legacy_table["detector"])
529 np.testing.assert_array_equal(prov.inputs["physical_filter"], legacy_table["physical_filter"])
530 np.testing.assert_array_equal(prov.inputs["day_obs"], legacy_table["day_obs"])
531 np.testing.assert_array_equal(prov.contributions["overlaps_center"], legacy_table["overlaps_center"])
532 np.testing.assert_array_equal(
533 prov.contributions["overlap_fraction"], legacy_table["overlap_fraction"]
534 )
535 np.testing.assert_array_equal(prov.contributions["weight"], legacy_table["weight"])
536 np.testing.assert_array_equal(prov.contributions["psf_shape_xx"], legacy_table["psf_shape_xx"])
537 np.testing.assert_array_equal(prov.contributions["psf_shape_yy"], legacy_table["psf_shape_yy"])
538 np.testing.assert_array_equal(prov.contributions["psf_shape_xy"], legacy_table["psf_shape_xy"])
539 np.testing.assert_array_equal(prov.contributions["psf_shape_flag"], legacy_table["psf_shape_flag"])
540 for row in prov.inputs:
541 polygon_key = ObservationIdentifiers(**{k: row[k] for k in row.keys() if k != "polygon"})
542 legacy_polygon = legacy_cell_coadd.common.visit_polygons[polygon_key]
543 tc.assertEqual(legacy_polygon, row["polygon"].to_legacy())
546def compare_psf_to_legacy(
547 tc: unittest.TestCase,
548 psf: PointSpreadFunction,
549 legacy_psf: Any,
550 points: YX[np.ndarray] | XY[np.ndarray] | None = None,
551 expect_legacy_raise_on_out_of_bounds: bool = False,
552) -> int:
553 """Compare a PSF model object to its legacy interface.
555 Parameters
556 ----------
557 tc
558 Test case object with assert methods to use.
559 psf
560 Point-spread function to test.
561 legacy_psf
562 Legacy `lsst.afw.detection.Psf` instance to compare with.
563 points
564 Points to evaluate the PSFs at. If not provided, the intersection of
565 the PSF bounding boxes are used to generate points on a grid.
566 expect_legacy_raise_on_out_of_bounds
567 If `True`, expect ``legacy_psf`` to raise
568 `lsst.afw.detection.InvalidPsfError` when evaluated at a position
569 considered out-of-bounds by ``psf``.
571 Returns
572 -------
573 `int`
574 The number of points actually tested.
575 """
576 from lsst.afw.detection import InvalidPsfError
578 if points is None:
579 points = psf.bounds.bbox.meshgrid(n=3).map(np.ravel)
580 legacy_points = arrays_to_legacy_points(points.x, points.y)
581 n_points_tested: int = 0
582 for p in legacy_points:
583 if not psf.bounds.contains(x=p.x, y=p.y):
584 if expect_legacy_raise_on_out_of_bounds:
585 with tc.assertRaises(InvalidPsfError):
586 legacy_psf.computeKernelImage(p)
587 continue
588 tc.assertEqual(psf.kernel_bbox, Box.from_legacy(legacy_psf.computeKernelBBox(p)))
589 tc.assertEqual(
590 psf.compute_kernel_image(x=p.x, y=p.y), Image.from_legacy(legacy_psf.computeKernelImage(p))
591 )
592 tc.assertEqual(
593 psf.compute_stellar_bbox(x=p.x, y=p.y), Box.from_legacy(legacy_psf.computeImageBBox(p))
594 )
595 tc.assertEqual(psf.compute_stellar_image(x=p.x, y=p.y), Image.from_legacy(legacy_psf.computeImage(p)))
596 n_points_tested += 1
597 return n_points_tested
600def compare_field_to_legacy(
601 tc: unittest.TestCase,
602 field: BaseField,
603 legacy_field: Any,
604 subimage_bbox: Box,
605) -> None:
606 """Test a Field object by comparing it to an equivalent
607 `lsst.afw.math.BoundedField`.
609 Parameters
610 ----------
611 tc
612 Test case object with assert methods to use.
613 field
614 Field to test.
615 legacy_field : ``lsst.afw.math.BoundedField``
616 Equivalent legacy bounded field.
617 subimage_bbox
618 Bounding box for full-image tests.
619 """
620 tc.assertEqual(field.bounds.bbox, Box.from_legacy(legacy_field.getBBox()))
621 # Pixel coordinates to test the numpy array interface with.
622 pixel_xy = field.bounds.bbox.meshgrid(n=5).map(np.ravel)
623 assert_close(tc, field(x=pixel_xy.x, y=pixel_xy.y), legacy_field.evaluate(pixel_xy.x, pixel_xy.y))
624 legacy_image_1 = Image(0, bbox=subimage_bbox, dtype=np.float64).to_legacy()
625 legacy_field.addToImage(legacy_image_1, overlapOnly=True)
626 assert_images_equal(tc, field.render(subimage_bbox), Image.from_legacy(legacy_image_1), rtol=1e-13)
629def compare_aperture_corrections_to_legacy(
630 tc: unittest.TestCase,
631 aperture_corrections: ApertureCorrectionMap,
632 legacy_ap_corr_map: Any,
633 subimage_bbox: Box,
634) -> None:
635 """Test an aperture correction `dict` by comparing it to an equivalent
636 `lsst.afw.image.ApCorrMap`.
638 Parameters
639 ----------
640 tc
641 Test case object with assert methods to use.
642 aperture_corrections
643 Dictionary to test.
644 legacy_ap_corr_map : ``lsst.afw.image.ApCorrMap``
645 Equivalent legacy aperture correction map.
646 subimage_bbox
647 Bounding box for full-image tests.
648 """
649 tc.assertEqual(aperture_corrections.keys(), set(legacy_ap_corr_map.keys()))
650 for name, field in aperture_corrections.items():
651 compare_field_to_legacy(tc, field, legacy_ap_corr_map[name], subimage_bbox)
654def compare_observation_summary_stats_to_legacy(
655 tc: unittest.TestCase,
656 summary_stats: ObservationSummaryStats,
657 legacy_summary_stats: Any,
658) -> None:
659 """Test an ObservationSummaryStats object by comparing it to an equivalent
660 `lsst.afw.image.ExposureSummaryStats`.
662 Parameters
663 ----------
664 tc
665 Test case object with assert methods to use.
666 summary_stats
667 Struct to test.
668 legacy : ``lsst.afw.image.ExposureSummaryStats``
669 Equivalent legacy struct.
670 """
671 for field in dataclasses.fields(legacy_summary_stats):
672 a = getattr(legacy_summary_stats, field.name)
673 b = getattr(summary_stats, field.name)
674 if isinstance(b, tuple):
675 for ai, bi in zip(a, b):
676 tc.assertTrue(ai == bi or (math.isnan(ai) and math.isnan(bi)), f"{field.name}: {a} != {b}")
677 else:
678 tc.assertTrue(a == b or (math.isnan(a) and math.isnan(b)), f"{field.name}: {a} != {b}")
681def compare_projection_to_legacy_wcs[F: Frame](
682 tc: unittest.TestCase,
683 projection: Projection[F],
684 legacy_wcs: Any,
685 pixel_frame: F,
686 subimage_bbox: Box,
687 is_fits: bool = False,
688) -> None:
689 """Test a Projection object by comparing it to an equivalent
690 `lsst.afw.geom.SkyWcs`.
692 Parameters
693 ----------
694 tc
695 Test case object with assert methods to use.
696 projection
697 Projection to test.
698 legacy_wcs : ``lsst.afw.geom.SkyWcs``
699 Equivalent legacy WCS.
700 pixel_frame
701 Expected pixel frame for the projection.
702 subimage_bbox
703 Bounding box of points to generate for tests.
704 is_fits
705 Whether this projection is expected to be exactly representable as a
706 FIT WCS. If `False` it is assumed to have a FITS approximation
707 attached instead.
708 """
709 # Pixel coordinates to test on over the subimage region of interest:
710 pixel_xy = subimage_bbox.meshgrid(step=50).map(np.ravel)
711 # Array indices of those pixel values (subtract off bbox starts):
712 subimage_array_xy = XY(x=pixel_xy.x - subimage_bbox.x.start, y=pixel_xy.y - subimage_bbox.y.start)
713 sky_coords = legacy_coords_to_astropy(
714 legacy_wcs.pixelToSky(arrays_to_legacy_points(pixel_xy.x, pixel_xy.y))
715 )
716 # Test transforming with the Projection itself, which also tests its
717 # nested Transform and an Astropy High-Level WCS view with no origin
718 # change.
719 check_projection(tc, projection, pixel_xy, sky_coords, pixel_frame)
720 # Also test the Astropy High-Level WCS view with an origin change to
721 # array indices.
722 check_astropy_wcs_interface(
723 tc, projection.as_astropy(subimage_bbox), subimage_array_xy, sky_coords, pixel_atol=1e-5
724 )
725 if is_fits:
726 fits_wcs = projection.as_fits_wcs(subimage_bbox, allow_approximation=True)
727 assert fits_wcs is not None
728 check_astropy_wcs_interface(tc, fits_wcs, subimage_array_xy, sky_coords, pixel_atol=1e-5)
729 # Use that FITS approximation to check that we can make a
730 # Projection from a FITS WCS, too.
731 fits_projection = Projection.from_fits_wcs(fits_wcs, pixel_frame)
732 check_projection(
733 tc,
734 fits_projection,
735 subimage_array_xy,
736 sky_coords,
737 pixel_frame,
738 pixel_atol=1e-5,
739 )
740 # We want Projections we create from a FITS WCS to be backed by an
741 # AST FrameSet so we can convert them into legacy
742 # `lsst.afw.geom.SkyWcs` objects if desired.
743 tc.assertIn("Begin FrameSet", fits_projection.show())
744 else:
745 tc.assertIsNone(projection.as_fits_wcs(subimage_bbox, allow_approximation=False))
746 # The legacy SkyWcs should instead have a FITS approximation
747 # attached; run the same tests on that.
748 assert projection.fits_approximation is not None
749 compare_projection_to_legacy_wcs(
750 tc,
751 projection.fits_approximation,
752 legacy_wcs.getFitsApproximation(),
753 pixel_frame,
754 subimage_bbox,
755 is_fits=True,
756 )
759def check_transform[I: Frame, O: Frame](
760 tc: unittest.TestCase,
761 transform: Transform[I, O],
762 input_xy: XY[np.ndarray],
763 output_xy: XY[np.ndarray],
764 in_frame: Frame,
765 out_frame: Frame,
766 *,
767 check_inverted: bool = True,
768 in_atol: u.Quantity | None = None,
769 out_atol: u.Quantity | None = None,
770) -> None:
771 """Test Transform against known arrays of input and output points.
773 Parameters
774 ----------
775 tc
776 Test case object with assert methods to use.
777 transform
778 Transform to test.
779 input_xy
780 Arrays of input points.
781 output_xy
782 Arrays of output points.
783 in_frame
784 Expected input frame.
785 out_frame
786 Expected output frame.
787 check_inverted
788 If `True`, recurse (once) to test the inverse transform.
789 in_atol
790 Expected absolute precision of input points.
791 out_atol
792 Expected absolute precision of output points.
793 """
794 tc.assertEqual(transform.in_frame, in_frame)
795 tc.assertEqual(transform.out_frame, out_frame)
796 in_atol_v = in_atol.to_value(in_frame.unit) if in_atol is not None else None
797 out_atol_v = out_atol.to_value(out_frame.unit) if out_atol is not None else None
798 # Test array interfaces.
799 test_output_xy = transform.apply_forward(x=input_xy.x, y=input_xy.y)
800 assert_close(tc, test_output_xy.x, output_xy.x, atol=out_atol_v)
801 assert_close(tc, test_output_xy.y, output_xy.y, atol=out_atol_v)
802 test_input_xy = transform.apply_inverse(x=output_xy.x, y=output_xy.y)
803 assert_close(tc, test_input_xy.x, input_xy.x, atol=in_atol_v)
804 assert_close(tc, test_input_xy.y, input_xy.y, atol=in_atol_v)
805 # Test scalar interfaces with numpy scalars.
806 for input_x, input_y, output_x, output_y in zip(input_xy.x, input_xy.y, output_xy.x, output_xy.y):
807 assert_close(tc, transform.apply_forward(x=input_x, y=input_y).x, output_x, atol=out_atol_v)
808 assert_close(tc, transform.apply_forward(x=input_x, y=input_y).y, output_y, atol=out_atol_v)
809 assert_close(tc, transform.apply_inverse(x=output_x, y=output_y).x, input_x, atol=in_atol_v)
810 assert_close(tc, transform.apply_inverse(x=output_x, y=output_y).y, input_y, atol=in_atol_v)
811 # Test quantity array interfaces.
812 input_q_xy = XY(x=input_xy.x * transform.in_frame.unit, y=input_xy.y * transform.in_frame.unit)
813 output_q_xy = XY(x=output_xy.x * transform.out_frame.unit, y=output_xy.y * transform.out_frame.unit)
814 test_output_q_xy = transform.apply_forward_q(x=input_q_xy.x, y=input_q_xy.y)
815 assert_close(tc, test_output_q_xy.x, output_q_xy.x, atol=out_atol)
816 assert_close(tc, test_output_q_xy.y, output_q_xy.y, atol=out_atol)
817 test_input_q_xy = transform.apply_inverse_q(x=output_q_xy.x, y=output_q_xy.y)
818 assert_close(tc, test_input_q_xy.x, input_q_xy.x, atol=in_atol)
819 assert_close(tc, test_input_q_xy.y, input_q_xy.y, atol=in_atol)
820 # Test quantity scalar interfaces.
821 for input_q_x, input_q_y, output_q_x, output_q_y in zip(
822 input_q_xy.x, input_q_xy.y, output_q_xy.x, output_q_xy.y
823 ):
824 assert_close(tc, transform.apply_forward_q(x=input_q_x, y=input_q_y).x, output_q_x, atol=out_atol)
825 assert_close(tc, transform.apply_forward_q(x=input_q_x, y=input_q_y).y, output_q_y, atol=out_atol)
826 assert_close(tc, transform.apply_inverse_q(x=output_q_x, y=output_q_y).x, input_q_x, atol=in_atol)
827 assert_close(tc, transform.apply_inverse_q(x=output_q_x, y=output_q_y).y, input_q_y, atol=in_atol)
828 if check_inverted:
829 # Test the inverse transform.
830 check_transform(
831 tc,
832 transform.inverted(),
833 output_xy,
834 input_xy,
835 out_frame,
836 in_frame,
837 check_inverted=False,
838 out_atol=in_atol,
839 in_atol=out_atol,
840 )
843def check_projection[P: Frame](
844 tc: unittest.TestCase,
845 projection: Projection[P],
846 pixel_xy: XY[np.ndarray],
847 sky_coords: SkyCoord,
848 pixel_frame: Frame,
849 *,
850 pixel_atol: float | None = None,
851 sky_atol: u.Quantity | None = None,
852) -> None:
853 """Test a `.Projection` instance against known arrays of pixel and sky
854 coordinates.
856 Parameters
857 ----------
858 tc
859 Test case object with assert methods to use.
860 projection
861 Projection to test.
862 pixel_xy
863 Arrays of pixel coordinates.
864 sky_coords
865 Corresponding sky coordinates.
866 pixel_frame
867 Expected pixel frame.
868 pixel_atol
869 Expected absolute precision of pixel points.
870 sky_atol
871 Expected absolute precision of sky coordinates.
872 """
873 tc.assertEqual(projection.pixel_frame, pixel_frame)
874 tc.assertEqual(projection.sky_frame, SkyFrame.ICRS)
875 sky_atol_v = sky_atol.to_value(SkyFrame.ICRS.unit) if sky_atol is not None else None
876 pixel_atol_q = pixel_atol * u.pix if pixel_atol is not None else None
877 # Test array interfaces.
878 test_pixel_xy = cast(XY[np.ndarray], projection.sky_to_pixel(sky_coords))
879 assert_close(tc, test_pixel_xy.x, pixel_xy.x, atol=pixel_atol)
880 assert_close(tc, test_pixel_xy.y, pixel_xy.y, atol=pixel_atol)
881 test_sky_astropy = projection.pixel_to_sky(x=pixel_xy.x, y=pixel_xy.y)
882 assert_close(tc, test_sky_astropy.ra, sky_coords.ra, atol=sky_atol_v)
883 assert_close(tc, test_sky_astropy.dec, sky_coords.dec, atol=sky_atol_v)
884 # Test scalar interfaces.
885 for pixel_x, pixel_y, sky_single in zip(pixel_xy.x, pixel_xy.y, sky_coords):
886 assert_close(tc, projection.sky_to_pixel(sky_single).x, pixel_x, atol=pixel_atol)
887 assert_close(tc, projection.sky_to_pixel(sky_single).y, pixel_y, atol=pixel_atol)
888 assert_close(tc, projection.pixel_to_sky(x=pixel_x, y=pixel_y).ra, sky_single.ra, atol=sky_atol_v)
889 assert_close(tc, projection.pixel_to_sky(x=pixel_x, y=pixel_y).dec, sky_single.dec, atol=sky_atol_v)
890 # Test the underlying Transform object.
891 sky_xy = XY(x=sky_coords.ra.to_value(u.rad), y=sky_coords.dec.to_value(u.rad))
892 check_transform(
893 tc,
894 projection.pixel_to_sky_transform,
895 pixel_xy,
896 sky_xy,
897 pixel_frame,
898 SkyFrame.ICRS,
899 check_inverted=False,
900 in_atol=pixel_atol_q,
901 out_atol=sky_atol,
902 )
903 check_transform(
904 tc,
905 projection.sky_to_pixel_transform,
906 sky_xy,
907 pixel_xy,
908 SkyFrame.ICRS,
909 pixel_frame,
910 check_inverted=False,
911 in_atol=sky_atol,
912 out_atol=pixel_atol_q,
913 )
914 # Test the Astropy interface adapter.
915 check_astropy_wcs_interface(
916 tc, projection.as_astropy(), pixel_xy, sky_coords, pixel_atol=pixel_atol, sky_atol=sky_atol
917 )
920def assert_projections_equal(
921 tc: unittest.TestCase,
922 a: Projection[Any] | None,
923 b: Projection[Any] | None,
924 expect_identity: bool | None = None,
925) -> None:
926 """Test that two `.Projection` instances are equivalent."""
927 if a is None and b is None:
928 return
929 assert a is not None and b is not None
930 match expect_identity:
931 case True:
932 tc.assertIs(a, b)
933 return
934 case False:
935 tc.assertIsNot(a, b)
936 case None if a is b:
937 return
938 tc.assertEqual(a.pixel_frame, b.pixel_frame)
939 tc.assertEqual(a.show(simplified=True), b.show(simplified=True))
940 assert_projections_equal(
941 tc, a.fits_approximation, cast(Projection[Any], b.fits_approximation), expect_identity=False
942 )
945def check_astropy_wcs_interface(
946 tc: unittest.TestCase,
947 wcs: astropy.wcs.wcsapi.BaseHighLevelWCS,
948 pixel_xy: XY[np.ndarray],
949 sky_coords: SkyCoord,
950 *,
951 pixel_atol: float | None = None,
952 sky_atol: u.Quantity | None = None,
953) -> None:
954 """Test an Astropy WCS instance against known arrays of pixel and
955 sky coordinates.
957 Parameters
958 ----------
959 tc
960 Test case object with assert methods to use.
961 wcs
962 Astropy WCS object to test.
963 pixel_xy
964 Arrays of pixel coordinates.
965 sky_coords
966 Corresponding sky coordinates.
967 pixel_atol
968 Expected absolute precision of pixel points.
969 sky_atol
970 Expected absolute precision of sky coordinates.
971 """
972 test_x, test_y = wcs.world_to_pixel(sky_coords)
973 assert_close(tc, test_x, pixel_xy.x, atol=pixel_atol)
974 assert_close(tc, test_y, pixel_xy.y, atol=pixel_atol)
975 test_sky_coords = wcs.pixel_to_world(pixel_xy.x, pixel_xy.y)
976 assert_close(tc, test_sky_coords.ra, sky_coords.ra, atol=sky_atol)
977 assert_close(tc, test_sky_coords.dec, sky_coords.dec, atol=sky_atol)
980def legacy_points_to_xy_array(legacy_points: list[Any]) -> XY[np.ndarray]:
981 """Convert a list of ``lsst.geom.Point2D`` objects to an `.XY` array."""
982 return XY(x=np.array([p.x for p in legacy_points]), y=np.array([p.y for p in legacy_points]))
985def legacy_coords_to_astropy(legacy_coords: list[Any]) -> SkyCoord:
986 """Convert a list of ``lsst.geom.SpherePoint`` objects to an Astropy
987 coordinate object.
988 """
989 return SkyCoord(
990 ra=np.array([p.getRa().asRadians() for p in legacy_coords]) * u.rad,
991 dec=np.array([p.getDec().asRadians() for p in legacy_coords]) * u.rad,
992 )
995def arrays_to_legacy_points(x: np.ndarray, y: np.ndarray) -> list[Any]:
996 """Convert arrays of ``x`` and ``y`` to a list of ``lsst.geom.Point2D``."""
997 from lsst.geom import Point2D
999 return [Point2D(x=xv, y=yv) for xv, yv in zip(x, y)]
1002def compare_amplifier_to_legacy(
1003 tc: unittest.TestCase,
1004 amplifier: Amplifier,
1005 legacy_amplifier: Any,
1006 *,
1007 is_raw_assembled: bool,
1008 expect_nominal_calibrations: bool = True,
1009) -> None:
1010 """Compare an `~.cameras.Amplifier` to a legacy
1011 `lsst.afw.cameraGeom.Amplifier`.
1012 """
1013 tc.assertEqual(legacy_amplifier.getName(), amplifier.name)
1014 tc.assertEqual(Box.from_legacy(legacy_amplifier.getBBox()), amplifier.bbox)
1015 if is_raw_assembled:
1016 raw_geom = amplifier.assembled_raw_geometry
1017 else:
1018 raw_geom = amplifier.unassembled_raw_geometry
1019 assert raw_geom is not None
1020 tc.assertEqual(ReadoutCorner.from_legacy(legacy_amplifier.getReadoutCorner()), raw_geom.readout_corner)
1021 tc.assertEqual(Box.from_legacy(legacy_amplifier.getRawBBox()), raw_geom.bbox)
1022 tc.assertEqual(Box.from_legacy(legacy_amplifier.getRawDataBBox()), raw_geom.data_bbox)
1023 tc.assertEqual(legacy_amplifier.getRawFlipX(), raw_geom.flip_x)
1024 tc.assertEqual(legacy_amplifier.getRawFlipY(), raw_geom.flip_y)
1025 tc.assertEqual(legacy_amplifier.getRawXYOffset().getX(), raw_geom.x_offset)
1026 tc.assertEqual(legacy_amplifier.getRawXYOffset().getY(), raw_geom.y_offset)
1027 tc.assertEqual(
1028 Box.from_legacy(legacy_amplifier.getRawHorizontalOverscanBBox()), raw_geom.horizontal_overscan_bbox
1029 )
1030 tc.assertEqual(
1031 Box.from_legacy(legacy_amplifier.getRawVerticalOverscanBBox()), raw_geom.vertical_overscan_bbox
1032 )
1033 tc.assertEqual(Box.from_legacy(legacy_amplifier.getRawPrescanBBox()), raw_geom.horizontal_prescan_bbox)
1034 if expect_nominal_calibrations:
1035 assert amplifier.nominal_calibrations is not None
1036 assert_equal_allow_nan(tc, legacy_amplifier.getGain(), amplifier.nominal_calibrations.gain)
1037 assert_equal_allow_nan(tc, legacy_amplifier.getReadNoise(), amplifier.nominal_calibrations.read_noise)
1038 assert_equal_allow_nan(
1039 tc, legacy_amplifier.getSaturation(), amplifier.nominal_calibrations.saturation
1040 )
1041 assert_equal_allow_nan(
1042 tc, legacy_amplifier.getSuspectLevel(), amplifier.nominal_calibrations.suspect_level
1043 )
1044 np.testing.assert_array_equal(
1045 legacy_amplifier.getLinearityCoeffs(), amplifier.nominal_calibrations.linearity_coefficients
1046 )
1047 tc.assertEqual(legacy_amplifier.getLinearityType(), amplifier.nominal_calibrations.linearity_type)
1050def compare_detector_to_legacy(
1051 tc: unittest.TestCase,
1052 detector: Detector,
1053 legacy_detector: Any,
1054 *,
1055 is_raw_assembled: bool,
1056 expect_nominal_calibrations: bool = True,
1057) -> None:
1058 """Compare a `~.cameras.Detector` to a `lsst.afw.cameraGeom.Detector`."""
1059 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, PIXELS
1061 tc.assertEqual(legacy_detector.getName(), detector.name)
1062 tc.assertEqual(legacy_detector.getId(), detector.id)
1063 tc.assertEqual(DetectorType.from_legacy(legacy_detector.getType()), detector.type)
1064 tc.assertEqual(Box.from_legacy(legacy_detector.getBBox()), detector.bbox)
1065 tc.assertEqual(legacy_detector.getSerial(), detector.serial)
1066 legacy_orientation = legacy_detector.getOrientation()
1067 tc.assertEqual(legacy_orientation.getFpPosition3().getX(), detector.orientation.focal_plane_x)
1068 tc.assertEqual(legacy_orientation.getFpPosition3().getY(), detector.orientation.focal_plane_y)
1069 tc.assertEqual(legacy_orientation.getFpPosition3().getZ(), detector.orientation.focal_plane_z)
1070 tc.assertEqual(legacy_orientation.getReferencePoint().getX(), detector.orientation.pixel_reference_x)
1071 tc.assertEqual(legacy_orientation.getReferencePoint().getY(), detector.orientation.pixel_reference_y)
1072 tc.assertEqual(legacy_orientation.getYaw().asRadians(), detector.orientation.yaw.to_value(u.rad))
1073 tc.assertEqual(legacy_orientation.getPitch().asRadians(), detector.orientation.pitch.to_value(u.rad))
1074 tc.assertEqual(legacy_orientation.getRoll().asRadians(), detector.orientation.roll.to_value(u.rad))
1075 tc.assertEqual(legacy_detector.getPixelSize().getX(), detector.pixel_size)
1076 tc.assertEqual(legacy_detector.getPhysicalType(), detector.physical_type)
1077 for amplifier, legacy_amplifier in zip(detector.amplifiers, legacy_detector.getAmplifiers(), strict=True):
1078 compare_amplifier_to_legacy(
1079 tc,
1080 amplifier,
1081 legacy_amplifier,
1082 is_raw_assembled=is_raw_assembled,
1083 expect_nominal_calibrations=expect_nominal_calibrations,
1084 )
1085 pixel_xy = detector.bbox.meshgrid(n=3).map(lambda z: z.ravel().astype(np.float64))
1086 pixel_legacy_points = arrays_to_legacy_points(y=pixel_xy.y, x=pixel_xy.x)
1087 fp_legacy_points = legacy_detector.transform(pixel_legacy_points, PIXELS, FOCAL_PLANE)
1088 check_transform(
1089 tc,
1090 detector.to_focal_plane,
1091 pixel_xy,
1092 legacy_points_to_xy_array(fp_legacy_points),
1093 detector.frame,
1094 detector.to_focal_plane.out_frame,
1095 in_atol=1e-9 * u.pix,
1096 out_atol=1e-7 * detector.to_focal_plane.out_frame.unit,
1097 )
1098 fa_legacy_points = legacy_detector.transform(pixel_legacy_points, PIXELS, FIELD_ANGLE)
1099 check_transform(
1100 tc,
1101 detector.to_field_angle,
1102 pixel_xy,
1103 legacy_points_to_xy_array(fa_legacy_points),
1104 detector.frame,
1105 detector.to_field_angle.out_frame,
1106 in_atol=1e-9 * u.pix,
1107 out_atol=1e-7 * u.arcsec,
1108 )