Coverage for tests/test_transforms.py: 75%
370 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -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
14import dataclasses
15import functools
16import os
17from typing import Any, ClassVar
19import astropy.units as u
20import numpy as np
21import pydantic
22import pytest
24from lsst.images import (
25 ICRS,
26 XY,
27 YX,
28 Box,
29 CameraFrameSet,
30 CameraFrameSetSerializationModel,
31 DetectorFrame,
32 FocalPlaneFrame,
33 GeneralFrame,
34 SkyProjection,
35 Transform,
36 TransformSerializationModel,
37)
38from lsst.images._transforms import _ast as astshim
39from lsst.images.fits import PointerModel
40from lsst.images.serialization import ArchiveTree, InputArchive, JsonRef, OutputArchive
41from lsst.images.tests import (
42 DP2_VISIT_DETECTOR_DATA_ID,
43 RoundtripFits,
44 RoundtripJson,
45 check_transform,
46 compare_sky_projection_to_legacy_wcs,
47 legacy_points_to_xy_array,
48 make_random_sky_projection,
49)
51EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
54@pytest.fixture(scope="session")
55def legacy_camera() -> Any:
56 """Return a legacy Camera loaded from camera.fits.
58 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.cameraGeom is
59 unavailable.
60 """
61 if EXTERNAL_DATA_DIR is None: 61 ↛ 63line 61 didn't jump to line 63 because the condition on line 61 was always true
62 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
63 try:
64 from lsst.afw.cameraGeom import Camera
65 except ImportError:
66 pytest.skip("'lsst.afw.cameraGeom' could not be imported.")
67 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "camera.fits")
68 return Camera.readFits(filename)
71@pytest.fixture(scope="session")
72def legacy_detector_wcs() -> dict[str, Any]:
73 """Return WCS-related objects read from visit_image.fits.
75 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.image is unavailable.
76 """
77 if EXTERNAL_DATA_DIR is None: 77 ↛ 79line 77 didn't jump to line 79 because the condition on line 77 was always true
78 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
79 try:
80 from lsst.afw.image import ExposureFitsReader
81 except ImportError:
82 pytest.skip("'lsst.afw.image' could not be imported.")
83 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits")
84 reader = ExposureFitsReader(filename)
85 return {
86 "legacy_wcs": reader.readWcs(),
87 "wcs_bbox": Box.from_legacy(reader.readDetector().getBBox()),
88 "subimage_bbox": Box.from_legacy(reader.readBBox()),
89 }
92def test_identity() -> None:
93 """Test an identity transform."""
94 frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4])
95 xy = frame.bbox.meshgrid().map(np.ravel)
96 identity = Transform.identity(frame)
97 check_transform(identity, xy, xy, frame, frame)
98 assert identity.decompose() == []
99 with RoundtripJson(identity) as roundtrip:
100 pass
101 check_transform(roundtrip.result, xy, xy, frame, frame)
104def test_transform_equality() -> None:
105 """Test Transform.__eq__ across all of its comparison branches."""
106 pixel_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4])
107 focal_plane = FocalPlaneFrame(instrument="LSSTCam", visit=1, unit=u.mm)
108 # A distinct frame for the in-frame and out-frame branches.
109 alt_frame = DetectorFrame(instrument="LSSTCam", visit=1, detector=12, bbox=Box.factory[:5, :4])
110 in_bounds = Box.factory[:5, :4]
111 out_bounds = Box.factory[:10, :8]
113 def make(
114 *,
115 in_frame: Any = pixel_frame,
116 out_frame: Any = focal_plane,
117 ast_mapping: astshim.Mapping | None = None,
118 in_bounds_: Box | None = in_bounds,
119 out_bounds_: Box | None = out_bounds,
120 components: Any = (),
121 ) -> Transform[Any, Any]:
122 return Transform(
123 in_frame,
124 out_frame,
125 ast_mapping if ast_mapping is not None else astshim.UnitMap(2),
126 in_bounds=in_bounds_,
127 out_bounds=out_bounds_,
128 components=components,
129 )
131 base = make()
133 # Identity short-circuit: an object is always equal to itself.
134 assert base == base
136 # Two independently constructed but equivalent transforms are equal,
137 # and equality is symmetric.
138 assert base == make()
139 assert make() == base
141 # Comparison against a non-Transform yields NotImplemented, so Python
142 # falls back to identity: the objects are unequal and != is True.
143 assert not (base == "not a transform")
144 assert base != "not a transform"
145 assert base != None # noqa: E711
146 assert base != 42
148 # Each remaining branch differs from base in exactly one attribute.
149 assert base != make(ast_mapping=astshim.ShiftMap([1.0, 2.0]))
150 assert base != make(in_bounds_=out_bounds)
151 assert base != make(out_bounds_=in_bounds)
152 assert base != make(in_frame=alt_frame)
153 assert base != make(out_frame=alt_frame)
154 assert base != make(components=[Transform.identity(alt_frame)])
157def test_sky_projection_equality() -> None:
158 """Test SkyProjection.__eq__ across all of its comparison branches."""
159 pixel_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4])
161 # Check the two failure modes.
162 with pytest.raises(ValueError):
163 SkyProjection(Transform(ICRS, ICRS, astshim.UnitMap(2)))
165 with pytest.raises(ValueError):
166 SkyProjection(Transform(pixel_frame, pixel_frame, astshim.UnitMap(2)))
168 def make_pixel_to_sky(ast_mapping: astshim.Mapping | None = None) -> Transform[Any, Any]:
169 mapping = ast_mapping if ast_mapping is not None else astshim.UnitMap(2)
170 return Transform(pixel_frame, ICRS, mapping)
172 base = SkyProjection(make_pixel_to_sky())
174 # Identity short-circuit: an object is always equal to itself.
175 assert base == base
177 # Two independently constructed but equivalent projections are equal.
178 assert base == SkyProjection(make_pixel_to_sky())
180 # Comparison against a non-SkyProjection yields NotImplemented.
181 assert not (base == "not a projection")
182 assert base != "not a projection"
183 assert base != None # noqa: E711
185 # Differ only in the pixel-to-sky transform.
186 assert base != SkyProjection(make_pixel_to_sky(astshim.ShiftMap([1.0, 2.0])))
188 # The fits_approximation branch: absent on base but present here.
189 with_approx = SkyProjection(
190 make_pixel_to_sky(), fits_approximation=make_pixel_to_sky(astshim.ShiftMap([0.1, 0.2]))
191 )
192 assert base != with_approx
194 # Equal pixel-to-sky and equal fits_approximations are equal.
195 with_approx_again = SkyProjection(
196 make_pixel_to_sky(), fits_approximation=make_pixel_to_sky(astshim.ShiftMap([0.1, 0.2]))
197 )
198 assert with_approx == with_approx_again
200 # Same pixel-to-sky transform but a different fits_approximation.
201 other_approx = SkyProjection(
202 make_pixel_to_sky(), fits_approximation=make_pixel_to_sky(astshim.ShiftMap([0.3, 0.4]))
203 )
204 assert with_approx != other_approx
207def test_affine_2x2() -> None:
208 """Test an affine transform constructed from a 2x2 matrix."""
209 in_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4])
210 out_frame = GeneralFrame(unit=u.pix)
211 transform_matrix = np.array([[2.0, 0.25], [-0.75, 0.8]])
212 in_xy = in_frame.bbox.meshgrid().map(np.ravel)
213 in_matrix = np.array([in_xy.x, in_xy.y])
214 out_matrix = np.dot(transform_matrix, in_matrix)
215 check_transform(
216 Transform.affine(in_frame, out_frame, transform_matrix),
217 in_xy,
218 XY(x=out_matrix[0, :], y=out_matrix[1, :]),
219 in_frame,
220 out_frame,
221 in_atol=1e-15 * u.pix,
222 out_atol=1e-15 * u.pix,
223 )
226def test_affine_3x3() -> None:
227 """Test an affine transform constructed from a 3x3 matrix."""
228 in_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4])
229 out_frame = GeneralFrame(unit=u.pix)
230 transform_matrix = np.array([[2.0, 0.25, -0.5], [-0.75, 0.8, 0.4], [0.0, 0.0, 1.0]])
231 in_xy = in_frame.bbox.meshgrid().map(np.ravel)
232 in_matrix = np.array([in_xy.x, in_xy.y, np.ones(in_xy.x.shape)])
233 out_matrix = np.dot(transform_matrix, in_matrix)
234 check_transform(
235 Transform.affine(in_frame, out_frame, transform_matrix),
236 in_xy,
237 XY(x=out_matrix[0, :], y=out_matrix[1, :]),
238 in_frame,
239 out_frame,
240 in_atol=1e-15 * u.pix,
241 out_atol=1e-15 * u.pix,
242 )
245def compare_to_legacy_camera(legacy_camera: Any, frame_set: CameraFrameSet) -> None:
246 """Assert that transforms extracted from a CameraFrameSet match the
247 legacy afw implementations.
248 """
249 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, PIXELS
250 from lsst.geom import Point2D
252 legacy_detector = legacy_camera[16]
253 pixel_legacy_points = [Point2D(50.0, 60.0), Point2D(801.2, 322.8), Point2D(33.5, 22.1)]
254 fp_legacy_points = [legacy_detector.transform(p, PIXELS, FOCAL_PLANE) for p in pixel_legacy_points]
255 fa_legacy_points = [legacy_detector.transform(p, PIXELS, FIELD_ANGLE) for p in pixel_legacy_points]
256 pixel_xy_array = legacy_points_to_xy_array(pixel_legacy_points)
257 fp_xy_array = legacy_points_to_xy_array(fp_legacy_points)
258 fa_xy_array = legacy_points_to_xy_array(fa_legacy_points)
259 # Test transforms extracted directly from the frame set.
260 pixel_to_fp = frame_set[frame_set.detector(16), frame_set.focal_plane()]
261 check_transform(pixel_to_fp, pixel_xy_array, fp_xy_array, frame_set.detector(16), frame_set.focal_plane())
262 pixel_to_fa = frame_set[frame_set.detector(16), frame_set.field_angle()]
263 check_transform(pixel_to_fa, pixel_xy_array, fa_xy_array, frame_set.detector(16), frame_set.field_angle())
264 fp_to_fa = frame_set[frame_set.focal_plane(), frame_set.field_angle()]
265 check_transform(fp_to_fa, fp_xy_array, fa_xy_array, frame_set.focal_plane(), frame_set.field_angle())
266 # Test a composition.
267 pixel_to_fa_indirect = pixel_to_fp.then(fp_to_fa)
268 check_transform(
269 pixel_to_fa_indirect,
270 pixel_xy_array,
271 fa_xy_array,
272 frame_set.detector(16),
273 frame_set.field_angle(),
274 )
275 pixel_to_fp_d, fp_to_fa_d = pixel_to_fa_indirect.decompose()
276 check_transform(
277 pixel_to_fp_d, pixel_xy_array, fp_xy_array, frame_set.detector(16), frame_set.focal_plane()
278 )
279 check_transform(fp_to_fa_d, fp_xy_array, fa_xy_array, frame_set.focal_plane(), frame_set.field_angle())
280 fa_to_fp_d, fp_to_pixel_d = pixel_to_fa_indirect.inverted().decompose()
281 check_transform(fa_to_fp_d, fa_xy_array, fp_xy_array, frame_set.field_angle(), frame_set.focal_plane())
282 check_transform(
283 fp_to_pixel_d, fp_xy_array, pixel_xy_array, frame_set.focal_plane(), frame_set.detector(16)
284 )
287def test_camera(legacy_camera: Any) -> None:
288 """Test CameraFrameSet construction, transforms, and FITS/JSON
289 serialization round-trips.
291 Also verifies the archive system's pointer and frame-set reference
292 machinery.
293 """
294 legacy_camera = legacy_camera
295 frame_set = CameraFrameSet.from_legacy(legacy_camera)
296 detector_id: int = DP2_VISIT_DETECTOR_DATA_ID["detector"]
297 compare_to_legacy_camera(legacy_camera, frame_set)
298 test_holder = FrameSetTestHolder(
299 frames=frame_set,
300 pixels_to_fp=frame_set[frame_set.detector(detector_id), frame_set.focal_plane()],
301 )
302 with RoundtripFits(test_holder) as roundtrip1:
303 assert len(roundtrip1.serialized.pixels_to_fp.frames) == 2
304 assert len(roundtrip1.serialized.pixels_to_fp.bounds) == 2
305 assert len(roundtrip1.serialized.pixels_to_fp.mappings) == 1
306 # Instead of storing the AST mapping directly, we should have
307 # stored a reference to the frame set:
308 assert isinstance(roundtrip1.serialized.pixels_to_fp.mappings[0], PointerModel)
309 compare_to_legacy_camera(legacy_camera, roundtrip1.result.frames)
310 assert roundtrip1.result.pixels_to_fp.in_frame == frame_set.detector(detector_id)
311 assert roundtrip1.result.pixels_to_fp.out_frame == frame_set.focal_plane()
312 assert (
313 roundtrip1.result.pixels_to_fp._ast_mapping.simplified().show()
314 == test_holder.pixels_to_fp._ast_mapping.simplified().show()
315 )
316 with RoundtripJson(test_holder) as roundtrip2:
317 assert len(roundtrip2.serialized.pixels_to_fp.frames) == 2
318 assert len(roundtrip2.serialized.pixels_to_fp.bounds) == 2
319 assert len(roundtrip2.serialized.pixels_to_fp.mappings) == 1
320 # Instead of storing the AST mapping directly, we should have
321 # stored a reference to the frame set:
322 assert isinstance(roundtrip2.serialized.pixels_to_fp.mappings[0], JsonRef)
323 raw_data = roundtrip2.inspect()
324 assert len(raw_data["indirect"]) == 1
325 assert raw_data["frames"] == {"$ref": "#/indirect/0"}
326 compare_to_legacy_camera(legacy_camera, roundtrip2.result.frames)
327 assert roundtrip2.result.pixels_to_fp.in_frame == frame_set.detector(detector_id)
328 assert roundtrip2.result.pixels_to_fp.out_frame == frame_set.focal_plane()
329 assert (
330 roundtrip2.result.pixels_to_fp._ast_mapping.simplified().show()
331 == test_holder.pixels_to_fp._ast_mapping.simplified().show()
332 )
335def test_fits_wcs_projection_to_legacy() -> None:
336 """Verify that a projection created by from_fits_wcs can be converted
337 to a legacy SkyWcs.
339 The AST pixel frame uses the domain PIXEL, while lsst.afw.geom.SkyWcs
340 requires PIXELS, so to_legacy has to rename it.
341 """
342 pytest.importorskip("lsst.afw.geom")
343 rng = np.random.default_rng(43)
344 bbox = Box.factory[75:275, 25:225]
345 pixel_frame = GeneralFrame(unit=u.pix)
346 sky_projection = make_random_sky_projection(rng, pixel_frame, bbox)
347 legacy_wcs = sky_projection.to_legacy()
348 compare_sky_projection_to_legacy_wcs(sky_projection, legacy_wcs, pixel_frame, bbox, is_fits=True)
349 # The conversion must not modify the projection in place: its own AST
350 # mapping keeps the PIXEL domain, and the conversion is repeatable.
351 frame_set = sky_projection.pixel_to_sky_transform._ast_mapping
352 assert isinstance(frame_set, astshim.FrameSet)
353 domains = {frame_set.getFrame(i, copy=False).domain for i in range(1, frame_set.nFrame + 1)}
354 assert "PIXEL" in domains
355 assert "PIXELS" not in domains
356 compare_sky_projection_to_legacy_wcs(
357 sky_projection, sky_projection.to_legacy(), pixel_frame, bbox, is_fits=True
358 )
361def test_detector_wcs(legacy_detector_wcs: dict[str, Any]) -> None:
362 """Test the Transform/SkyProjection representation of a detector WCS."""
363 legacy_wcs = legacy_detector_wcs["legacy_wcs"]
364 wcs_bbox = legacy_detector_wcs["wcs_bbox"]
365 subimage_bbox = legacy_detector_wcs["subimage_bbox"]
366 detector_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=wcs_bbox)
367 sky_projection = SkyProjection.from_legacy(legacy_wcs, detector_frame)
368 assert sky_projection.fits_approximation is not None
369 compare_sky_projection_to_legacy_wcs(sky_projection, legacy_wcs, detector_frame, subimage_bbox)
370 # When we convert from a legacy SkyWcs, the internal AST Mapping needs
371 # to really be an AST FrameSet in order to be able to convert back.
372 assert "Begin FrameSet" in sky_projection.show()
373 compare_sky_projection_to_legacy_wcs(
374 sky_projection, sky_projection.to_legacy(), detector_frame, subimage_bbox
375 )
376 assert "Begin FrameSet" in sky_projection.fits_approximation.show()
377 compare_sky_projection_to_legacy_wcs(
378 sky_projection.fits_approximation,
379 sky_projection.fits_approximation.to_legacy(),
380 detector_frame,
381 subimage_bbox,
382 is_fits=True,
383 )
384 with RoundtripJson(sky_projection, "SkyProjection") as roundtrip:
385 pass
386 compare_sky_projection_to_legacy_wcs(roundtrip.result, legacy_wcs, detector_frame, subimage_bbox)
387 # The AST FrameSet-ness needs to propagate through serialization.
388 assert "Begin FrameSet" in roundtrip.result.show()
389 compare_sky_projection_to_legacy_wcs(
390 sky_projection, roundtrip.result.to_legacy(), detector_frame, subimage_bbox
391 )
392 with RoundtripJson(sky_projection.fits_approximation, "SkyProjection") as roundtrip:
393 pass
394 compare_sky_projection_to_legacy_wcs(
395 roundtrip.result,
396 legacy_wcs.getFitsApproximation(),
397 detector_frame,
398 subimage_bbox,
399 is_fits=True,
400 )
401 assert "Begin FrameSet" in roundtrip.result.show()
402 compare_sky_projection_to_legacy_wcs(
403 sky_projection.fits_approximation,
404 roundtrip.result.to_legacy(),
405 detector_frame,
406 subimage_bbox,
407 is_fits=True,
408 )
411@dataclasses.dataclass
412class FrameSetTestHolder:
413 """A top-level object that holds a CameraFrameSet and a transform
414 extracted from it, for testing archive pointers and frame set references.
415 """
417 frames: CameraFrameSet
418 pixels_to_fp: Transform[DetectorFrame, FocalPlaneFrame]
420 def serialize[P: pydantic.BaseModel](self, archive: OutputArchive[P]) -> FrameSetTestHolderModel[P]:
421 frames_model = archive.serialize_frame_set(
422 "frames", self.frames, self.frames.serialize, key=id(self.frames)
423 )
424 pixels_to_fp_model = archive.serialize_direct(
425 "pixels_to_fp", functools.partial(self.pixels_to_fp.serialize, use_frame_sets=True)
426 )
427 return FrameSetTestHolderModel[P](frames=frames_model, pixels_to_fp=pixels_to_fp_model)
429 @staticmethod
430 def _get_archive_tree_type[P: pydantic.BaseModel](
431 pointer_type: type[P],
432 ) -> type[FrameSetTestHolderModel[P]]:
433 return FrameSetTestHolderModel[pointer_type] # type: ignore
436class FrameSetTestHolderModel[P: pydantic.BaseModel](ArchiveTree):
437 """The serialization model for FrameSetTestHolder."""
439 SCHEMA_NAME: ClassVar[str] = "_test_frame_set_holder"
440 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
441 MIN_READ_VERSION: ClassVar[int] = 1
442 PUBLIC_TYPE: ClassVar[type] = FrameSetTestHolder
444 frames: CameraFrameSetSerializationModel | P
445 pixels_to_fp: TransformSerializationModel[P]
447 def deserialize(self, archive: InputArchive[Any]) -> FrameSetTestHolder:
448 assert not isinstance(self.frames, CameraFrameSetSerializationModel), "Archive pointer expected."
449 frames = archive.deserialize_pointer(
450 self.frames, CameraFrameSetSerializationModel, CameraFrameSetSerializationModel.deserialize
451 )
452 pixels_to_fp = self.pixels_to_fp.deserialize(archive)
453 return FrameSetTestHolder(frames, pixels_to_fp)
456@dataclasses.dataclass
457class _BroadcastTestData:
458 """Shared inputs for broadcasting/scalar tests on Transform and
459 SkyProjection.
460 """
462 in_frame: DetectorFrame
463 out_frame: GeneralFrame
464 matrix: np.ndarray
465 scalar_x: float
466 scalar_y: float
467 xv: list[int]
468 yv: list[int]
469 sky_proj: SkyProjection[DetectorFrame]
472@pytest.fixture
473def broadcast_test_data() -> _BroadcastTestData:
474 """Return shared inputs for broadcasting/scalar tests."""
475 in_frame = DetectorFrame(instrument="Inst", visit=1, detector=1, bbox=Box.factory[0:20, 0:20])
476 return _BroadcastTestData(
477 in_frame=in_frame,
478 out_frame=GeneralFrame(unit=u.pix),
479 matrix=np.array([[2.0, 0.5], [-0.3, 1.5]]),
480 scalar_x=3.0,
481 scalar_y=7.0,
482 xv=[1, 2, 3],
483 yv=[4, 5, 6],
484 sky_proj=make_random_sky_projection(np.random.default_rng(42), in_frame, in_frame.bbox),
485 )
488def test_apply_forward_scalar(broadcast_test_data: _BroadcastTestData) -> None:
489 """Verify that apply_forward and apply_inverse accept scalar x/y and return
490 scalar floats, and that the _q variants accept scalar Quantity inputs.
491 """
492 t = broadcast_test_data.sky_proj.pixel_to_sky_transform
493 # apply_forward with Python float scalars should return XY of floats.
494 result_fwd = t.apply_forward(x=broadcast_test_data.scalar_x, y=broadcast_test_data.scalar_y)
495 assert type(result_fwd.x) is float
496 assert type(result_fwd.y) is float
497 # Values must match the corresponding single-element array call.
498 ref_fwd = t.apply_forward(
499 x=np.array([broadcast_test_data.scalar_x]), y=np.array([broadcast_test_data.scalar_y])
500 )
501 assert result_fwd.x == ref_fwd.x[0]
502 assert result_fwd.y == ref_fwd.y[0]
503 # apply_inverse round-trips back to the original scalars.
504 result_inv = t.apply_inverse(x=result_fwd.x, y=result_fwd.y)
505 assert type(result_inv.x) is float
506 assert type(result_inv.y) is float
507 np.testing.assert_allclose(result_inv.x, broadcast_test_data.scalar_x, atol=1e-12)
508 np.testing.assert_allclose(result_inv.y, broadcast_test_data.scalar_y, atol=1e-12)
509 # apply_forward_q / apply_inverse_q with scalar Quantity inputs.
510 x_q = broadcast_test_data.scalar_x * t.in_frame.unit
511 y_q = broadcast_test_data.scalar_y * t.in_frame.unit
512 result_fwd_q = t.apply_forward_q(x=x_q, y=y_q)
513 assert result_fwd_q.x.shape == ()
514 assert result_fwd_q.y.shape == ()
515 np.testing.assert_allclose(result_fwd_q.x.to_value(t.out_frame.unit), result_fwd.x, atol=1e-12)
516 result_inv_q = t.apply_inverse_q(x=result_fwd_q.x, y=result_fwd_q.y)
517 assert result_inv_q.x.shape == ()
518 np.testing.assert_allclose(
519 result_inv_q.x.to_value(t.in_frame.unit), broadcast_test_data.scalar_x, atol=1e-12
520 )
523def test_apply_array_like_and_integer_input(broadcast_test_data: _BroadcastTestData) -> None:
524 """Verify that apply_forward accepts Python lists and integer-dtype
525 arrays, returning float64 ndarray results consistent with float64
526 array input.
527 """
528 t = broadcast_test_data.sky_proj.pixel_to_sky_transform
529 # Python list input should return an ndarray.
530 result_list = t.apply_forward(x=broadcast_test_data.xv, y=broadcast_test_data.yv)
531 assert isinstance(result_list.x, np.ndarray)
532 assert isinstance(result_list.y, np.ndarray)
533 ref = t.apply_forward(x=np.array(broadcast_test_data.xv), y=np.array(broadcast_test_data.yv))
534 np.testing.assert_array_equal(result_list.x, ref.x)
535 np.testing.assert_array_equal(result_list.y, ref.y)
536 # Integer dtype arrays should not raise and should return float64.
537 xi = np.array(broadcast_test_data.xv, dtype=np.int32)
538 yi = np.array(broadcast_test_data.yv, dtype=np.int32)
539 result_int = t.apply_forward(x=xi, y=yi)
540 assert result_int.x.dtype == np.float64
541 assert result_int.y.dtype == np.float64
542 np.testing.assert_array_equal(result_int.x, ref.x)
543 np.testing.assert_array_equal(result_int.y, ref.y)
546def test_apply_broadcast(broadcast_test_data: _BroadcastTestData) -> None:
547 """Verify that apply_forward and apply_inverse broadcast x and y like
548 a NumPy ufunc, in both 1-D and 2-D cases.
549 """
550 t = broadcast_test_data.sky_proj.pixel_to_sky_transform
551 xv = np.array(broadcast_test_data.xv)
552 yv = np.array(broadcast_test_data.yv + [7])
553 # 1-D broadcast: array x, scalar y.
554 result_1d = t.apply_forward(x=xv, y=broadcast_test_data.scalar_y)
555 assert isinstance(result_1d.x, np.ndarray)
556 assert result_1d.x.shape == xv.shape
557 ref_1d = t.apply_forward(x=xv, y=np.full_like(xv, broadcast_test_data.scalar_y))
558 np.testing.assert_array_equal(result_1d.x, ref_1d.x)
559 np.testing.assert_array_equal(result_1d.y, ref_1d.y)
560 # 2-D broadcast: column x (M,1) × row y (1,N) -> (M,N).
561 x2d = xv[:, np.newaxis] # shape (3, 1)
562 y2d = yv[np.newaxis, :] # shape (1, 4)
563 result_2d = t.apply_forward(x=x2d, y=y2d)
564 assert result_2d.x.shape == (3, 4)
565 assert result_2d.y.shape == (3, 4)
566 # Values must match the fully expanded meshgrid call.
567 xmesh, ymesh = np.meshgrid(xv, yv, indexing="ij")
568 ref_2d = t.apply_forward(x=xmesh, y=ymesh)
569 np.testing.assert_array_equal(result_2d.x, ref_2d.x)
570 np.testing.assert_array_equal(result_2d.y, ref_2d.y)
571 # apply_inverse also broadcasts.
572 result_inv_2d = t.apply_inverse(x=result_2d.x, y=result_2d.y)
573 assert result_inv_2d.x.shape == (3, 4)
574 np.testing.assert_allclose(result_inv_2d.x, xmesh, atol=1e-12)
575 np.testing.assert_allclose(result_inv_2d.y, ymesh, atol=1e-12)
578def test_sky_projection_broadcast(broadcast_test_data: _BroadcastTestData) -> None:
579 """Verify that SkyProjection.pixel_to_sky, sky_to_pixel, and the
580 Astropy view broadcast x and y like a NumPy ufunc.
581 """
582 p = broadcast_test_data
583 xv = np.array(p.xv)
584 yv = np.array(p.yv + [7])
585 # 1-D broadcast: array x, scalar y.
586 sc_1d = p.sky_proj.pixel_to_sky(x=xv, y=p.scalar_y)
587 assert sc_1d.shape == xv.shape
588 ref_1d = p.sky_proj.pixel_to_sky(x=xv, y=np.full_like(xv, p.scalar_y))
589 np.testing.assert_allclose(sc_1d.ra.rad, ref_1d.ra.rad, atol=1e-12)
590 np.testing.assert_allclose(sc_1d.dec.rad, ref_1d.dec.rad, atol=1e-12)
591 # 2-D broadcast: column x (M,1) × row y (1,N) -> (M,N).
592 x2d = xv[:, np.newaxis] # shape (3, 1)
593 y2d = yv[np.newaxis, :] # shape (1, 4)
594 sc_2d = p.sky_proj.pixel_to_sky(x=x2d, y=y2d)
595 assert sc_2d.shape == (3, 4)
596 xmesh, ymesh = np.meshgrid(xv, yv, indexing="ij")
597 ref_2d = p.sky_proj.pixel_to_sky(x=xmesh, y=ymesh)
598 np.testing.assert_allclose(sc_2d.ra.rad, ref_2d.ra.rad, atol=1e-12)
599 np.testing.assert_allclose(sc_2d.dec.rad, ref_2d.dec.rad, atol=1e-12)
600 # sky_to_pixel round-trips back to the original grid.
601 pix_2d = p.sky_proj.sky_to_pixel(sc_2d)
602 assert pix_2d.x.shape == (3, 4)
603 np.testing.assert_allclose(pix_2d.x, xmesh, atol=1e-9)
604 np.testing.assert_allclose(pix_2d.y, ymesh, atol=1e-9)
605 # SkyProjectionAstropyView.pixel_to_world_values also broadcasts.
606 view = p.sky_proj.as_astropy()
607 world_2d = view.pixel_to_world_values(x2d, y2d)
608 assert world_2d[0].shape == (3, 4)
609 assert world_2d[1].shape == (3, 4)
610 np.testing.assert_allclose(world_2d[0], ref_2d.ra.rad, atol=1e-12)
611 np.testing.assert_allclose(world_2d[1], ref_2d.dec.rad, atol=1e-12)
612 # SkyProjectionAstropyView.world_to_pixel_values also broadcasts.
613 ra_2d = ref_2d.ra.rad[:, np.newaxis, :] # (3, 1, 4) — over-broadcast to check
614 dec_2d = ref_2d.dec.rad[np.newaxis, :, :] # (1, 3, 4)
615 pix_world = view.world_to_pixel_values(ra_2d, dec_2d)
616 assert pix_world[0].shape == (3, 3, 4)
619def test_apply_xy_yx(broadcast_test_data: _BroadcastTestData) -> None:
620 """Verify that apply_forward, apply_inverse, and the _q variants accept
621 XY and YX positional arguments, producing results identical to the
622 equivalent x=/y= keyword calls.
623 """
624 p = broadcast_test_data
625 t = p.sky_proj.pixel_to_sky_transform
626 sx, sy = p.scalar_x, p.scalar_y
627 xv, yv = np.array(p.xv, dtype=float), np.array(p.yv, dtype=float)
629 # --- apply_forward: scalar ---
630 ref_fwd = t.apply_forward(x=sx, y=sy)
631 assert t.apply_forward(XY(sx, sy)) == ref_fwd
632 assert t.apply_forward(YX(sy, sx)) == ref_fwd
634 # --- apply_forward: array ---
635 ref_fwd_arr = t.apply_forward(x=xv, y=yv)
636 np.testing.assert_array_equal(t.apply_forward(XY(xv, yv)).x, ref_fwd_arr.x)
637 np.testing.assert_array_equal(t.apply_forward(YX(yv, xv)).x, ref_fwd_arr.x)
639 # --- apply_inverse: scalar ---
640 ref_inv = t.apply_inverse(x=ref_fwd.x, y=ref_fwd.y)
641 assert t.apply_inverse(XY(ref_fwd.x, ref_fwd.y)) == ref_inv
642 assert t.apply_inverse(YX(ref_fwd.y, ref_fwd.x)) == ref_inv
644 # --- apply_forward_q: scalar ---
645 x_q = sx * t.in_frame.unit
646 y_q = sy * t.in_frame.unit
647 ref_fwd_q = t.apply_forward_q(x=x_q, y=y_q)
648 result_q = t.apply_forward_q(XY(x_q, y_q))
649 np.testing.assert_allclose(result_q.x.value, ref_fwd_q.x.value, atol=1e-12)
650 result_q_yx = t.apply_forward_q(YX(y_q, x_q))
651 np.testing.assert_allclose(result_q_yx.x.value, ref_fwd_q.x.value, atol=1e-12)
653 # --- apply_inverse_q: scalar ---
654 ref_inv_q = t.apply_inverse_q(x=ref_fwd_q.x, y=ref_fwd_q.y)
655 result_inv_q = t.apply_inverse_q(XY(ref_fwd_q.x, ref_fwd_q.y))
656 np.testing.assert_allclose(result_inv_q.x.value, ref_inv_q.x.value, atol=1e-12)
658 # --- TypeError on bad combinations ---
659 with pytest.raises(TypeError):
660 t.apply_forward(XY(sx, sy), x=sx)
661 with pytest.raises(TypeError):
662 t.apply_forward(YX(sy, sx), y=sy)
663 with pytest.raises(TypeError):
664 t.apply_forward()
667def test_pixel_to_sky_xy_yx(broadcast_test_data: _BroadcastTestData) -> None:
668 """Verify that SkyProjection.pixel_to_sky accepts XY and YX positional
669 arguments, producing results identical to the x=/y= keyword form.
670 """
671 p = broadcast_test_data
672 sx, sy = p.scalar_x, p.scalar_y
673 xv, yv = np.array(p.xv, dtype=float), np.array(p.yv, dtype=float)
675 # Scalar XY and YX.
676 ref_scalar = p.sky_proj.pixel_to_sky(x=sx, y=sy)
677 result_xy = p.sky_proj.pixel_to_sky(XY(sx, sy))
678 result_yx = p.sky_proj.pixel_to_sky(YX(sy, sx))
679 np.testing.assert_allclose(result_xy.ra.rad, ref_scalar.ra.rad, atol=1e-12)
680 np.testing.assert_allclose(result_yx.ra.rad, ref_scalar.ra.rad, atol=1e-12)
682 # Array XY and YX.
683 ref_array = p.sky_proj.pixel_to_sky(x=xv, y=yv)
684 result_xy_arr = p.sky_proj.pixel_to_sky(XY(xv, yv))
685 result_yx_arr = p.sky_proj.pixel_to_sky(YX(yv, xv))
686 np.testing.assert_allclose(result_xy_arr.ra.rad, ref_array.ra.rad, atol=1e-12)
687 np.testing.assert_allclose(result_yx_arr.ra.rad, ref_array.ra.rad, atol=1e-12)
689 # TypeError on bad combinations.
690 with pytest.raises(TypeError):
691 p.sky_proj.pixel_to_sky(XY(sx, sy), x=sx)
692 with pytest.raises(TypeError):
693 p.sky_proj.pixel_to_sky(YX(sy, sx), y=sy)
694 with pytest.raises(TypeError):
695 p.sky_proj.pixel_to_sky()