Coverage for tests/test_fields.py: 93%
361 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 08:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 08:38 +0000
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 os
15from collections.abc import Callable
16from typing import Any
18import astropy.units as u
19import numpy as np
20import pytest
22from lsst.images import XY, YX, Box, Image
23from lsst.images.describe import DescribableMixin, Report
24from lsst.images.fields import (
25 BaseField,
26 ChebyshevField,
27 ProductField,
28 SplineField,
29 SumField,
30 field_from_legacy,
31 field_from_legacy_background,
32)
33from lsst.images.tests import (
34 RoundtripFits,
35 assert_close,
36 assert_images_equal,
37 compare_field_to_legacy,
38)
40try:
41 from lsst.afw.image import MaskedImageF
42 from lsst.afw.math import BackgroundList as LegacyBackgroundList
43 from lsst.afw.math import BackgroundMI
44 from lsst.afw.math import Chebyshev1Function2D as LegacyChebyshev1Function2D
45 from lsst.afw.math import ChebyshevBoundedField as LegacyChebyshevBoundedField
46 from lsst.afw.math import ProductBoundedField as LegacyProductBoundedField
47 from lsst.geom import Box2D as LegacyBox2D
49 HAVE_LEGACY = True
50except ImportError:
51 HAVE_LEGACY = False
52 type LegacyBackgroundList = Any # type: ignore[no-redef]
53 type LegacyBox2D = Any # type: ignore[no-redef]
54 type LegacyChebyshev1Function2D = Any # type: ignore[no-redef]
55 type LegacyChebyshevBoundedField = Any # type: ignore[no-redef]
56 type LegacyProductBoundedField = Any # type: ignore[no-redef]
59EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
60TEST_BOX = Box.factory[6:32, -7:26]
63@pytest.fixture(scope="session")
64def legacy_visit_background() -> LegacyBackgroundList:
65 """Load and return an `lsst.afw.math.BackgroundList`.
67 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.math is unavailable.
68 """
69 if EXTERNAL_DATA_DIR is None: 69 ↛ 71line 69 didn't jump to line 71 because the condition on line 69 was always true
70 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
71 if not HAVE_LEGACY:
72 pytest.skip("This test requires lsst.afw.math to be importable.")
73 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image_background.fits")
74 return LegacyBackgroundList.readFits(filename)
77def _make_test_chebyshev_field() -> ChebyshevField:
78 return ChebyshevField(TEST_BOX, np.array([[0.5, -0.25], [0.40, 0.0]]))
81def _make_test_spline_field() -> SplineField:
82 rng = np.random.default_rng(10)
83 return SplineField(
84 TEST_BOX,
85 rng.standard_normal(size=(6, 7)),
86 y=TEST_BOX.y.linspace(6),
87 x=TEST_BOX.x.linspace(7),
88 )
91def _make_test_product_field() -> ProductField:
92 return ProductField([_make_test_spline_field(), _make_test_chebyshev_field()])
95def _make_test_sum_field() -> SumField:
96 return SumField([_make_test_spline_field(), _make_test_chebyshev_field()])
99def _make_legacy_chebyshev_field() -> LegacyChebyshevBoundedField:
100 if not HAVE_LEGACY: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 pytest.skip("Legacy LSST packages could not be imported.")
102 rng = np.random.default_rng(10)
103 cheby_coeffs = rng.random((6, 3))
104 return LegacyChebyshevBoundedField(TEST_BOX.to_legacy(), cheby_coeffs)
107def _make_legacy_product_field() -> LegacyProductBoundedField:
108 if not HAVE_LEGACY: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 pytest.skip("Legacy LSST packages could not be imported.")
110 rng = np.random.default_rng(11)
111 cheby2 = LegacyChebyshevBoundedField(TEST_BOX.to_legacy(), rng.standard_normal(size=(2, 2)))
112 return LegacyProductBoundedField([_make_legacy_chebyshev_field(), cheby2])
115def check_evaluation_consistency(field: BaseField) -> None:
116 """Check that __call__ and render agree."""
117 image_1 = field.render()
118 p = field.bounds.bbox.meshgrid()
119 image_2 = Image(field(x=p.x, y=p.y), bbox=field.bounds.bbox, unit=field.unit)
120 assert_images_equal(image_1, image_2)
121 scaled_field = field * 2.0
122 image_3 = scaled_field.render()
123 image_3.array *= 0.5
124 assert_images_equal(image_1, image_3)
125 image_4 = field.render(Box.factory[9:11, -3:1])
126 assert_images_equal(image_4, image_1[image_4.bbox])
129@pytest.mark.parametrize(
130 "factory",
131 [
132 _make_test_chebyshev_field,
133 _make_test_spline_field,
134 _make_test_product_field,
135 _make_test_sum_field,
136 ],
137)
138def test_evaluation_consistency(factory: Callable[[], BaseField]) -> None:
139 """Test that __call__ and render agree."""
140 check_evaluation_consistency(factory())
143@pytest.mark.parametrize(
144 "factory",
145 [
146 _make_test_chebyshev_field,
147 _make_test_spline_field,
148 _make_test_product_field,
149 _make_test_sum_field,
150 ],
151)
152def test_units(factory: Callable[[], BaseField]) -> None:
153 """Test that fields correctly propagats and check units."""
154 field = factory()
155 assert field.unit is None
156 with_units_1 = field * u.nJy
157 assert with_units_1.unit == u.nJy
158 assert with_units_1(x=np.array([0.0]), y=np.array([10.0]), quantity=True).unit == u.nJy
159 image_1 = with_units_1.render(bbox=Box.factory[10:12, 0:3])
160 assert image_1.unit == u.nJy
161 assert (with_units_1 * 2.0).unit == u.nJy
162 assert (with_units_1 / u.arcsec**2).unit == u.nJy / u.arcsec**2
165def test_chebyshev_call_limits() -> None:
166 """Test that ChebyshevField evaluates correctly at first order at the
167 corners of its box.
168 """
169 cheby = _make_test_chebyshev_field()
170 result = cheby(x=np.array([-7.5, 25.5, 25.5, -7.5]), y=np.array([5.5, 5.5, 31.5, 31.5]))
171 assert result[0] == 0.5 + 0.25 - 0.4 # [x=-1, y=-1] after remap
172 assert result[1] == 0.5 - 0.25 - 0.4 # [x=1, y=-1] after remap
173 assert result[2] == 0.5 - 0.25 + 0.4 # [x=1, y=1] after remap
174 assert result[3] == 0.5 + 0.25 + 0.4 # [x=-1, y=1] after remap
177def test_chebyshev_attributes() -> None:
178 """Test the basic properties of a ChebyshevField."""
179 cheby = _make_test_chebyshev_field()
180 assert cheby.bounds == TEST_BOX
181 assert cheby.unit is None
182 assert cheby.x_order == 1
183 assert cheby.y_order == 1
184 assert cheby.order == 1
185 np.testing.assert_array_equal(cheby.coefficients, np.array([[0.5, -0.25], [0.40, 0.0]]))
188def test_chebyshev_fit() -> None:
189 """Test that ChebyshevField.fit recovers the original coefficients with
190 zero residuals.
191 """
192 cheby = _make_test_chebyshev_field()
193 rng = np.random.default_rng(22)
194 data_image = cheby.render()
195 cheby2 = ChebyshevField.fit(TEST_BOX, data_image.array, order=1, y=TEST_BOX.y.arange, x=TEST_BOX.x.arange)
196 assert cheby2.bounds == TEST_BOX
197 np.testing.assert_array_almost_equal(cheby2.coefficients, cheby.coefficients)
198 # Fit to order 2 in x (will get us extra zero-valued coefficients):
199 cheby3 = ChebyshevField.fit(
200 TEST_BOX, data_image.array, x_order=2, y_order=1, y=TEST_BOX.y.arange, x=TEST_BOX.x.arange
201 )
202 assert cheby3.bounds == TEST_BOX
203 np.testing.assert_array_almost_equal(
204 cheby3.coefficients,
205 np.array([[0.5, -0.25, 0.0], [0.40, 0.0, 0.0]], dtype=np.float64),
206 )
207 # Fit with triangular=False:
208 cheby4 = ChebyshevField.fit(
209 TEST_BOX, data_image.array, order=1, y=TEST_BOX.y.arange, x=TEST_BOX.x.arange, triangular=False
210 )
211 assert cheby4.bounds == TEST_BOX
212 np.testing.assert_array_almost_equal(cheby4.coefficients, cheby.coefficients)
213 # Fit with weights.
214 cheby5 = ChebyshevField.fit(
215 TEST_BOX,
216 data_image.array,
217 order=1,
218 y=TEST_BOX.y.arange,
219 x=TEST_BOX.x.arange,
220 weight=rng.uniform(0.8, 1.2, size=data_image.array.shape),
221 )
222 assert cheby5.bounds == TEST_BOX
223 np.testing.assert_array_almost_equal(cheby5.coefficients, cheby.coefficients)
224 # Fit to a Quantity.
225 cheby6 = ChebyshevField.fit(
226 TEST_BOX, data_image.array * u.nJy, order=1, y=TEST_BOX.y.arange, x=TEST_BOX.x.arange
227 )
228 assert cheby6.bounds == TEST_BOX
229 assert cheby6.unit == u.nJy
230 np.testing.assert_array_almost_equal(cheby6.coefficients, cheby.coefficients)
231 # Fit with units provided separately.
232 cheby7 = ChebyshevField.fit(
233 TEST_BOX, data_image.array, order=1, y=TEST_BOX.y.arange, x=TEST_BOX.x.arange, unit=u.nJy
234 )
235 assert cheby7.bounds == TEST_BOX
236 assert cheby7.unit == u.nJy
237 np.testing.assert_array_almost_equal(cheby7.coefficients, cheby.coefficients)
238 # Fit with x and y labeling every data point.
239 m = TEST_BOX.meshgrid()
240 cheby8 = ChebyshevField.fit(TEST_BOX, data_image.array, order=1, y=m.y, x=m.x)
241 assert cheby8.bounds == TEST_BOX
242 np.testing.assert_array_almost_equal(cheby8.coefficients, cheby.coefficients)
243 # Fit with x and y labeling every data point plus weights.
244 cheby9 = ChebyshevField.fit(
245 TEST_BOX,
246 data_image.array,
247 order=1,
248 y=m.y,
249 x=m.x,
250 weight=rng.uniform(0.8, 1.2, size=data_image.array.shape),
251 )
252 assert cheby9.bounds == TEST_BOX
253 np.testing.assert_array_almost_equal(cheby9.coefficients, cheby.coefficients)
254 # Fit with one data point replaced by NaN (should be ignored).
255 new_data = data_image.array.copy()
256 new_data[5, 7] = np.nan
257 cheby10 = ChebyshevField.fit(TEST_BOX, new_data, order=1, y=TEST_BOX.y.arange, x=TEST_BOX.x.arange)
258 assert cheby10.bounds == TEST_BOX
259 np.testing.assert_array_almost_equal(cheby10.coefficients, cheby.coefficients)
262def test_spline_knot_evaluation() -> None:
263 """Test that SplineField evaluates to its input data at the knot
264 positions.
265 """
266 spline = _make_test_spline_field()
267 xv, yv = np.meshgrid(spline.x, spline.y)
268 result = spline(x=xv, y=yv)
269 np.testing.assert_array_almost_equal(result, spline.data)
272def test_product_evaluation() -> None:
273 """Test that ProductField.__call__ against direct calls to its operands."""
274 product = _make_test_product_field()
275 cheby = _make_test_chebyshev_field()
276 spline = _make_test_spline_field()
277 xv, yv = TEST_BOX.meshgrid(n=3)
278 z = product(x=xv, y=yv)
279 np.testing.assert_array_equal(z, cheby(x=xv, y=yv) * spline(x=xv, y=yv))
282def test_product_units() -> None:
283 """Test that ProductField correctly propagates and combines units."""
284 cheby = _make_test_chebyshev_field()
285 spline = _make_test_spline_field()
286 assert ProductField([cheby, spline * u.nJy]).unit == u.nJy
287 assert ProductField([cheby * u.nJy, spline]).unit == u.nJy
288 assert ProductField([cheby * u.nJy, spline / u.arcsec**2]).unit == u.nJy / u.arcsec**2
291def test_sum_evaluation() -> None:
292 """Test that SumField.__call__ against direct calls to its operands."""
293 sum_ = _make_test_sum_field()
294 cheby = _make_test_chebyshev_field()
295 spline = _make_test_spline_field()
296 xv, yv = TEST_BOX.meshgrid(n=3)
297 z = sum_(x=xv, y=yv)
298 np.testing.assert_array_equal(z, cheby(x=xv, y=yv) + spline(x=xv, y=yv))
301def test_sum_units() -> None:
302 """Test that SumField correctly propagates units and raises on incompatible
303 units.
304 """
305 cheby = _make_test_chebyshev_field()
306 spline = _make_test_spline_field()
307 with pytest.raises(u.UnitConversionError):
308 SumField([cheby, spline * u.nJy])
309 with pytest.raises(u.UnitConversionError):
310 SumField([cheby * u.nJy, spline])
311 # Test a SumField where the operands have different but compatible units.
312 mixed = SumField([cheby * u.rad, spline * u.deg])
313 assert mixed.unit == u.rad
314 small_box = Box.factory[10:12, 2:5]
315 cheby_render = cheby.render(small_box)
316 spline_render = spline.render(small_box)
317 mixed_render = mixed.render(small_box)
318 assert mixed_render.unit == u.rad
319 np.testing.assert_array_almost_equal(
320 mixed_render.array, cheby_render.array + (spline_render.array * np.pi / 180.0)
321 )
322 check_evaluation_consistency(mixed)
325def test_chebyshev_roundtrip() -> None:
326 """Test converting ChebyshevField from and to legacy, and serializing it
327 in between.
328 """
329 legacy_cheby = _make_legacy_chebyshev_field()
330 cheby = field_from_legacy(legacy_cheby)
331 assert isinstance(cheby, ChebyshevField)
332 compare_field_to_legacy(cheby, legacy_cheby, subimage_bbox=Box.factory[8:12, -3:2])
333 with RoundtripFits(cheby) as roundtrip:
334 pass
335 compare_field_to_legacy(roundtrip.result, legacy_cheby, subimage_bbox=Box.factory[8:12, -3:2])
336 compare_field_to_legacy(roundtrip.result, cheby.to_legacy(), subimage_bbox=Box.factory[8:12, -3:2])
339def test_product_roundtrip() -> None:
340 """Test converting ProductField from and to legacy, and serializing it
341 in between.
342 """
343 legacy_product = _make_legacy_product_field()
344 product = field_from_legacy(legacy_product)
345 assert isinstance(product, ProductField)
346 compare_field_to_legacy(product, legacy_product, subimage_bbox=Box.factory[8:12, -3:2])
347 with RoundtripFits(product) as roundtrip:
348 pass
349 compare_field_to_legacy(roundtrip.result, legacy_product, subimage_bbox=Box.factory[8:12, -3:2])
350 compare_field_to_legacy(roundtrip.result, product.to_legacy(), subimage_bbox=Box.factory[8:12, -3:2])
353def test_spline_simple() -> None:
354 """Test SplineField against BackgroundMI with no missing data."""
355 if not HAVE_LEGACY: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true
356 pytest.skip("Legacy LSST packages could not be imported.")
357 rng = np.random.default_rng(23)
358 bins = MaskedImageF(Box.factory[0:5, 0:6].to_legacy())
359 bins.image.array[:, :] = rng.standard_normal(bins.image.array.shape)
360 bins.variance.array[::] = 1.0
361 legacy_bg = BackgroundMI(TEST_BOX.to_legacy(), bins)
362 spline = field_from_legacy_background(legacy_bg)
363 render_bbox = TEST_BOX.padded(-3)
364 assert_images_equal(
365 spline.render(render_bbox),
366 Image.from_legacy(
367 legacy_bg.getImageF(render_bbox.to_legacy(), legacy_bg.getBackgroundControl().getInterpStyle())
368 ),
369 rtol=1e-7,
370 )
373def test_spline_one_nan() -> None:
374 """Test SplineField against BackgroundMI with one missing data point."""
375 if not HAVE_LEGACY: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true
376 pytest.skip("Legacy LSST packages could not be imported.")
377 rng = np.random.default_rng(24)
378 bins = MaskedImageF(Box.factory[0:7, 0:6].to_legacy())
379 bins.image.array[:, :] = rng.standard_normal(bins.image.array.shape)
380 bins.image.array[3, 2] = np.nan
381 bins.variance.array[::] = 1.0
382 legacy_bg = BackgroundMI(TEST_BOX.to_legacy(), bins)
383 spline = field_from_legacy_background(legacy_bg)
384 render_bbox = TEST_BOX.padded(-3)
385 assert_images_equal(
386 spline.render(render_bbox),
387 Image.from_legacy(
388 legacy_bg.getImageF(
389 render_bbox.to_legacy(),
390 legacy_bg.getBackgroundControl().getInterpStyle(),
391 )
392 ),
393 rtol=1e-7,
394 )
397def test_chebyshev1_function2() -> None:
398 """Verify ChebyshevField.from_legacy_function2 and to_legacy_function2
399 round-trip.
400 """
401 if not HAVE_LEGACY: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 pytest.skip("Legacy LSST packages could not be imported.")
403 rng = np.random.default_rng(25)
404 legacy_func2a = LegacyChebyshev1Function2D(4, LegacyBox2D(TEST_BOX.to_legacy()))
405 legacy_func2a.setParameters(rng.standard_normal(legacy_func2a.getNParameters()))
406 field = ChebyshevField.from_legacy_function2(legacy_func2a)
407 legacy_func2b = field.to_legacy_function2()
408 assert field.bounds == TEST_BOX
409 xy_array = TEST_BOX.meshgrid(4)
410 z_array = field(x=xy_array.x, y=xy_array.y)
411 for z, x, y in zip(z_array.flat, xy_array.x.flat, xy_array.y.flat):
412 assert_close(legacy_func2a(x, y), z)
413 assert_close(legacy_func2b(x, y), z)
416def test_visit_background(legacy_visit_background: LegacyBackgroundList) -> None:
417 """Test field_from_legacy_background against a real visit image
418 background.
419 """
420 bg_field = field_from_legacy_background(legacy_visit_background)
421 assert_images_equal(bg_field.render(), Image.from_legacy(legacy_visit_background.getImage()), rtol=1e-6)
424# Scalar x/y inside TEST_BOX used by the broadcasting/scalar tests below.
425_SCALAR_X = 9.0
426_SCALAR_Y = 18.5
427_SCALAR_X_INT = 9
428_SCALAR_Y_INT = 18
431@pytest.mark.parametrize(
432 "factory",
433 [
434 _make_test_chebyshev_field,
435 _make_test_spline_field,
436 _make_test_product_field,
437 _make_test_sum_field,
438 ],
439)
440def test_call_scalar(factory: Callable[[], BaseField]) -> None:
441 """Verify that __call__ accepts scalar x/y and returns a scalar float,
442 and returns a 0-d Quantity when quantity=True.
443 """
444 field = factory()
445 # quantity=False: Python int and float scalars should return float.
446 result_float = field(x=_SCALAR_X, y=_SCALAR_Y)
447 assert type(result_float) is float
448 result_int = field(x=_SCALAR_X_INT, y=_SCALAR_Y_INT)
449 assert type(result_int) is float
450 # Values must match the corresponding single-element array call.
451 ref_float = field(x=np.array([_SCALAR_X]), y=np.array([_SCALAR_Y]))[0]
452 assert result_float == ref_float
453 ref_int = field(x=np.array([float(_SCALAR_X_INT)]), y=np.array([float(_SCALAR_Y_INT)]))[0]
454 assert result_int == ref_int
455 # quantity=True: scalar input should yield a 0-d Quantity.
456 field_with_units = field * u.nJy
457 result_q = field_with_units(x=_SCALAR_X, y=_SCALAR_Y, quantity=True)
458 assert isinstance(result_q, u.Quantity)
459 assert result_q.shape == ()
460 assert result_q.unit == u.nJy
461 ref_q = field_with_units(x=np.array([_SCALAR_X]), y=np.array([_SCALAR_Y]), quantity=True)
462 assert result_q.value == ref_q[0].value
465@pytest.mark.parametrize(
466 "factory",
467 [
468 _make_test_chebyshev_field,
469 _make_test_spline_field,
470 _make_test_product_field,
471 _make_test_sum_field,
472 ],
473)
474def test_call_array_like_and_integer_input(factory: Callable[[], BaseField]) -> None:
475 """Verify that __call__ accepts Python lists and integer arrays, returning
476 float64 ndarray results consistent with float64 array input.
477 """
478 field = factory()
479 xv = [_SCALAR_X, _SCALAR_X + 2.0, _SCALAR_X + 4.0]
480 yv = [_SCALAR_Y, _SCALAR_Y + 1.0, _SCALAR_Y + 2.0]
481 # Python list input should return an ndarray.
482 result_list = field(x=xv, y=yv)
483 assert isinstance(result_list, np.ndarray)
484 ref = field(x=np.array(xv), y=np.array(yv))
485 np.testing.assert_array_equal(result_list, ref)
486 # Integer array input should not raise and should return float64.
487 xi = np.array([_SCALAR_X_INT, _SCALAR_X_INT + 2, _SCALAR_X_INT + 4], dtype=np.int32)
488 yi = np.array([_SCALAR_Y_INT, _SCALAR_Y_INT + 1, _SCALAR_Y_INT + 2], dtype=np.int32)
489 result_int = field(x=xi, y=yi)
490 assert isinstance(result_int, np.ndarray)
491 assert result_int.dtype == np.float64
492 ref_int = field(x=xi.astype(np.float64), y=yi.astype(np.float64))
493 np.testing.assert_array_equal(result_int, ref_int)
496@pytest.mark.parametrize(
497 "factory",
498 [
499 _make_test_chebyshev_field,
500 _make_test_spline_field,
501 _make_test_product_field,
502 _make_test_sum_field,
503 ],
504)
505def test_call_broadcast(factory: Callable[[], BaseField]) -> None:
506 """Verify that __call__ broadcasts x and y like a NumPy ufunc, in both
507 1-D and 2-D cases.
508 """
509 field = factory()
510 xv = np.linspace(_SCALAR_X, _SCALAR_X + 4.0, 5)
511 yv = np.linspace(_SCALAR_Y, _SCALAR_Y + 3.0, 4)
512 # 1-D broadcast: array x, scalar y.
513 result_1d = field(x=xv, y=_SCALAR_Y)
514 assert isinstance(result_1d, np.ndarray)
515 assert result_1d.shape == xv.shape
516 ref_1d = field(x=xv, y=np.full_like(xv, _SCALAR_Y))
517 np.testing.assert_array_equal(result_1d, ref_1d)
518 # 2-D broadcast: column x (M,1), row y (1,N) -> (M,N).
519 x2d = xv[:, np.newaxis] # shape (5, 1)
520 y2d = yv[np.newaxis, :] # shape (1, 4)
521 result_2d = field(x=x2d, y=y2d)
522 assert isinstance(result_2d, np.ndarray)
523 assert result_2d.shape == (xv.size, yv.size)
524 # Values must match the fully expanded meshgrid call.
525 xmesh, ymesh = np.meshgrid(xv, yv, indexing="ij")
526 ref_2d = field(x=xmesh, y=ymesh)
527 np.testing.assert_array_equal(result_2d, ref_2d)
530@pytest.mark.parametrize(
531 "factory",
532 [
533 _make_test_chebyshev_field,
534 _make_test_spline_field,
535 _make_test_product_field,
536 _make_test_sum_field,
537 ],
538)
539def test_call_float32_input(factory: Callable[[], BaseField]) -> None:
540 """Verify that float32 inputs produce float64 output with correct values,
541 without silent precision loss.
542 """
543 field = factory()
544 x32 = np.array([_SCALAR_X, _SCALAR_X + 2.0, _SCALAR_X + 4.0], dtype=np.float32)
545 y32 = np.array([_SCALAR_Y, _SCALAR_Y + 1.0, _SCALAR_Y + 2.0], dtype=np.float32)
546 result = field(x=x32, y=y32)
547 assert isinstance(result, np.ndarray)
548 assert result.dtype == np.float64
549 ref = field(x=x32.astype(np.float64), y=y32.astype(np.float64))
550 np.testing.assert_array_equal(result, ref)
553@pytest.mark.parametrize(
554 "factory",
555 [
556 _make_test_chebyshev_field,
557 _make_test_spline_field,
558 _make_test_product_field,
559 _make_test_sum_field,
560 ],
561)
562def test_call_xy_yx(factory: Callable[[], BaseField]) -> None:
563 """Verify that __call__ accepts XY and YX positional arguments, producing
564 results identical to the equivalent x=/y= keyword call.
565 """
566 field = factory()
567 # Scalar XY and YX — return type must be float and values must match.
568 ref_scalar = field(x=_SCALAR_X, y=_SCALAR_Y)
569 assert field(XY(x=_SCALAR_X, y=_SCALAR_Y)) == ref_scalar
570 assert field(YX(y=_SCALAR_Y, x=_SCALAR_X)) == ref_scalar
571 # Array XY and YX — return type must be ndarray and values must match.
572 xv = np.array([_SCALAR_X, _SCALAR_X + 2.0, _SCALAR_X + 4.0])
573 yv = np.array([_SCALAR_Y, _SCALAR_Y + 1.0, _SCALAR_Y + 2.0])
574 ref_array = field(x=xv, y=yv)
575 np.testing.assert_array_equal(field(XY(xv, yv)), ref_array)
576 np.testing.assert_array_equal(field(YX(yv, xv)), ref_array)
577 # quantity=True still works alongside an XY positional argument.
578 field_with_units = field * u.nJy
579 ref_q = field_with_units(x=_SCALAR_X, y=_SCALAR_Y, quantity=True)
580 result_q = field_with_units(XY(x=_SCALAR_X, y=_SCALAR_Y), quantity=True)
581 assert isinstance(result_q, u.Quantity)
582 assert result_q.value == ref_q.value
583 # Mixing a positional point with explicit x= or y= must raise TypeError.
584 with pytest.raises(TypeError):
585 field(XY(x=_SCALAR_X, y=_SCALAR_Y), x=_SCALAR_X)
586 with pytest.raises(TypeError):
587 field(YX(y=_SCALAR_Y, x=_SCALAR_X), y=_SCALAR_Y)
590def test_chebyshev_field_describe() -> None:
591 """ChebyshevField._describe returns a Report with order and bounds."""
592 field = _make_test_chebyshev_field()
593 assert isinstance(field, DescribableMixin)
594 report = field._describe()
595 assert isinstance(report, Report)
596 assert report.type_name == "ChebyshevField"
597 labels = {f.label for f in report.fields}
598 assert "order" in labels
599 assert "x_order" in labels
600 assert "y_order" in labels
601 assert "bounds" in labels
604def test_spline_field_describe() -> None:
605 """SplineField._describe returns a Report with grid_shape and bounds."""
606 field = _make_test_spline_field()
607 assert isinstance(field, DescribableMixin)
608 report = field._describe()
609 assert isinstance(report, Report)
610 assert report.type_name == "SplineField"
611 labels = {f.label for f in report.fields}
612 assert "grid_shape" in labels
613 assert "bounds" in labels
616def test_product_field_describe() -> None:
617 """ProductField._describe returns a Report with operand children."""
618 field = _make_test_product_field()
619 assert isinstance(field, DescribableMixin)
620 report = field._describe()
621 assert isinstance(report, Report)
622 assert report.type_name == "ProductField"
623 assert len(report.children) == 2
624 assert report.summary is not None
625 assert "2 operands" in report.summary
626 labels = {f.label for f in report.fields}
627 assert "bounds" in labels
630def test_sum_field_describe() -> None:
631 """SumField._describe returns a Report with operand children."""
632 field = _make_test_sum_field()
633 assert isinstance(field, DescribableMixin)
634 report = field._describe()
635 assert isinstance(report, Report)
636 assert report.type_name == "SumField"
637 assert len(report.children) == 2
638 assert report.summary is not None
639 assert "2 operands" in report.summary
640 labels = {f.label for f in report.fields}
641 assert "bounds" in labels