Skip to content

Commit a6b07cd

Browse files
authored
Merge pull request #465 from PyAutoLabs/claude/autoarray-pixel-scales-int-tuple-wfxlnj
fix: widen int / numpy scalar pixel_scales to a tuple
2 parents eb174fe + 8298d74 commit a6b07cd

4 files changed

Lines changed: 177 additions & 16 deletions

File tree

autoarray/geometry/geometry_util.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,27 @@ def convert_shape_native_1d(shape_native: Union[int, Tuple[int]]) -> Tuple[int]:
3232

3333
def convert_pixel_scales_1d(pixel_scales: ty.PixelScales) -> Tuple[float]:
3434
"""
35-
Convert an input pixel scale of type `float` to a tuple `(float,)`. If the input is already a
36-
`(float,)` tuple it is returned unchanged.
35+
Convert an input pixel scale given as a single real scalar to a tuple `(float,)`. If the
36+
input is already a `(float,)` tuple it is returned unchanged.
3737
38-
This enables users to input the pixel scale as a single float and have the type automatically
39-
normalised to `(float,)` which is used internally by 1D data structures.
38+
This enables users to input the pixel scale as a single number and have the type
39+
automatically normalised to `(float,)` which is used internally by 1D data structures.
40+
41+
Any concrete real scalar is widened — `int`, `float`, `np.integer` and `np.floating` — not
42+
just an exact `float`. An `int` is a natural thing to type by hand, and an `np.floating` is
43+
what indexing a numpy array or reading a FITS header returns, so both reach this function on
44+
paths a user would consider ordinary. The widened value is cast to a Python `float`, so the
45+
tuple this returns is always `(float,)` regardless of what went in.
46+
47+
A `bool` is deliberately *not* treated as a scalar here (see
48+
:func:`autoarray.validate.is_concrete_scalar`), and neither is a JAX tracer — a traced value
49+
passes through untouched so the function stays safe inside a `jax.jit`.
4050
4151
Parameters
4252
----------
4353
pixel_scales
44-
The pixel scale to convert, either as a plain `float` or a 1-element tuple `(float,)`.
54+
The pixel scale to convert, either as a plain real scalar or a 1-element tuple
55+
`(float,)`.
4556
4657
Returns
4758
-------
@@ -56,8 +67,8 @@ def convert_pixel_scales_1d(pixel_scales: ty.PixelScales) -> Tuple[float]:
5667

5768
validate.validate_pixel_scales(pixel_scales=pixel_scales)
5869

59-
if type(pixel_scales) is float:
60-
pixel_scales = (pixel_scales,)
70+
if validate.is_concrete_scalar(pixel_scales):
71+
pixel_scales = (float(pixel_scales),)
6172

6273
return pixel_scales
6374

@@ -197,17 +208,28 @@ def scaled_coordinates_1d_from(
197208

198209
def convert_pixel_scales_2d(pixel_scales: ty.PixelScales) -> Tuple[float, float]:
199210
"""
200-
Convert an input pixel scale of type `float` to a tuple `(float, float)`. If the input is
201-
already type `(float, float)` it is returned unchanged.
211+
Convert an input pixel scale given as a single real scalar to a tuple `(float, float)`. If
212+
the input is already type `(float, float)` it is returned unchanged.
213+
214+
This enables users to input the pixel scale as a single number and have the type
215+
automatically normalised to `(float, float)` which is used internally for rectangular 2D
216+
grids (where both axes share the same pixel scale).
217+
218+
Any concrete real scalar is widened — `int`, `float`, `np.integer` and `np.floating` — not
219+
just an exact `float`. An `int` is a natural thing to type by hand, and an `np.floating` is
220+
what indexing a numpy array or reading a FITS header returns, so both reach this function on
221+
paths a user would consider ordinary. The widened value is cast to a Python `float`, so the
222+
tuple this returns is always `(float, float)` regardless of what went in.
202223
203-
This enables users to input the pixel scale as a single float and have the type automatically
204-
normalised to `(float, float)` which is used internally for rectangular 2D grids (where
205-
both axes share the same pixel scale).
224+
A `bool` is deliberately *not* treated as a scalar here (see
225+
:func:`autoarray.validate.is_concrete_scalar`), and neither is a JAX tracer — a traced value
226+
passes through untouched so the function stays safe inside a `jax.jit`.
206227
207228
Parameters
208229
----------
209230
pixel_scales
210-
The pixel scale to convert, either as a plain `float` or a 2-element tuple `(float, float)`.
231+
The pixel scale to convert, either as a plain real scalar or a 2-element tuple
232+
`(float, float)`.
211233
212234
Returns
213235
-------
@@ -228,8 +250,8 @@ def convert_pixel_scales_2d(pixel_scales: ty.PixelScales) -> Tuple[float, float]
228250

229251
validate.validate_pixel_scales(pixel_scales=pixel_scales)
230252

231-
if type(pixel_scales) is float:
232-
pixel_scales = (pixel_scales, pixel_scales)
253+
if validate.is_concrete_scalar(pixel_scales):
254+
pixel_scales = (float(pixel_scales), float(pixel_scales))
233255

234256
return pixel_scales
235257

autoarray/type.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import numpy as np
22
from typing import List, Tuple, Union
33

4-
PixelScales = Union[Tuple[float], Tuple[float, float], float]
4+
# A pixel scale may be given per-axis as a tuple, or as a single real scalar which
5+
# `geometry_util.convert_pixel_scales_{1d,2d}` widens to that tuple. The scalar forms are
6+
# listed out because the widening accepts any real scalar, not just an exact `float`.
7+
PixelScales = Union[
8+
Tuple[float], Tuple[float, float], float, int, np.floating, np.integer
9+
]
510

611

712
from autoarray.mask.mask_1d import Mask1D

test_autoarray/geometry/test_geometry_util.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,89 @@
33
import pytest
44

55

6+
class _NotAConcreteScalar:
7+
"""
8+
Stand-in for a JAX tracer: not a concrete Python/NumPy scalar, and raising if anything
9+
tries to resolve it to a bool, exactly as a tracer does inside `jax.jit`. Mirrors the
10+
stand-in in `test_autoarray/test_validate.py` — unit tests here are NumPy-only.
11+
"""
12+
13+
def __bool__(self):
14+
raise AssertionError("a tracer must never be resolved to a bool")
15+
16+
17+
@pytest.mark.parametrize(
18+
"pixel_scales", [1, 1.0, np.float64(1.0), np.int32(1), np.float32(1.0)]
19+
)
20+
def test__convert_pixel_scales_1d__widens_any_real_scalar(pixel_scales):
21+
"""
22+
Any concrete real scalar widens, not just an exact `float`. `int` is what a user types by
23+
hand; `np.floating` is what indexing an array or reading a FITS header returns.
24+
"""
25+
assert aa.util.geometry.convert_pixel_scales_1d(pixel_scales=pixel_scales) == (1.0,)
26+
27+
28+
@pytest.mark.parametrize(
29+
"pixel_scales", [1, 1.0, np.float64(1.0), np.int32(1), np.float32(1.0)]
30+
)
31+
def test__convert_pixel_scales_2d__widens_any_real_scalar(pixel_scales):
32+
assert aa.util.geometry.convert_pixel_scales_2d(pixel_scales=pixel_scales) == (
33+
1.0,
34+
1.0,
35+
)
36+
37+
38+
@pytest.mark.parametrize("pixel_scales", [1, np.float64(1.0), np.int32(1)])
39+
def test__convert_pixel_scales__widened_entries_are_python_floats(pixel_scales):
40+
"""
41+
The widened value is cast, so an `int` or a NumPy scalar never reaches the geometry
42+
stored on a mask. `1 == 1.0` in Python, so the cast has to be asserted on the type.
43+
"""
44+
(entry_1d,) = aa.util.geometry.convert_pixel_scales_1d(pixel_scales=pixel_scales)
45+
assert type(entry_1d) is float
46+
47+
for entry in aa.util.geometry.convert_pixel_scales_2d(pixel_scales=pixel_scales):
48+
assert type(entry) is float
49+
50+
51+
def test__convert_pixel_scales__tuple_input_is_returned_unchanged():
52+
pixel_scales_1d = (1.0,)
53+
assert (
54+
aa.util.geometry.convert_pixel_scales_1d(pixel_scales=pixel_scales_1d)
55+
is pixel_scales_1d
56+
)
57+
58+
pixel_scales_2d = (1.0, 2.0)
59+
assert (
60+
aa.util.geometry.convert_pixel_scales_2d(pixel_scales=pixel_scales_2d)
61+
is pixel_scales_2d
62+
)
63+
64+
65+
def test__convert_pixel_scales__a_tracer_passes_through_untouched():
66+
"""Inside a `jax.jit` the value is traced; widening it would resolve it to a bool."""
67+
tracer_like = _NotAConcreteScalar()
68+
69+
assert (
70+
aa.util.geometry.convert_pixel_scales_1d(pixel_scales=tracer_like)
71+
is tracer_like
72+
)
73+
assert (
74+
aa.util.geometry.convert_pixel_scales_2d(pixel_scales=tracer_like)
75+
is tracer_like
76+
)
77+
78+
79+
def test__convert_pixel_scales__a_bool_is_not_treated_as_a_scalar():
80+
"""
81+
`bool` is a subclass of `int`, but `True` reaching a pixel scale is a different mistake
82+
than the ones this widening serves — `validate.is_concrete_scalar` excludes it, so it is
83+
not silently accepted as a pixel scale of 1.0.
84+
"""
85+
assert aa.util.geometry.convert_pixel_scales_1d(pixel_scales=True) is True
86+
assert aa.util.geometry.convert_pixel_scales_2d(pixel_scales=True) is True
87+
88+
689
def test__central_pixel_coordinates_1d_from():
790
central_pixel_coordinates = aa.util.geometry.central_pixel_coordinates_1d_from(
891
shape_slim=(3,)

test_autoarray/test_validate.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,57 @@ def test__b6__control__valid_pixel_scales_still_build():
124124
assert array.pixel_scales == (0.1, 0.2)
125125

126126

127+
@pytest.mark.parametrize("pixel_scales", [0, -1, np.int32(0), np.int32(-1)])
128+
def test__b6__the_guards_still_fire_on_integer_scalars(pixel_scales):
129+
"""
130+
The guards run before the widening, so broadening what counts as a scalar must not open a
131+
hole for the integer forms of the same bad input.
132+
"""
133+
with pytest.raises(ValueError, match="pixel_scales"):
134+
aa.Array2D.no_mask(values=np.ones((5, 5)), pixel_scales=pixel_scales)
135+
136+
137+
@pytest.mark.parametrize(
138+
"pixel_scales", [np.float64(0.0), np.float64(-0.1), np.float64("nan")]
139+
)
140+
def test__b6__the_guards_still_fire_on_numpy_scalars(pixel_scales):
141+
with pytest.raises(ValueError, match="pixel_scales"):
142+
aa.Array2D.no_mask(values=np.ones((5, 5)), pixel_scales=pixel_scales)
143+
144+
145+
@pytest.mark.parametrize("pixel_scales", [(0, 1), (1, -1)])
146+
def test__b6__the_guards_still_fire_on_integer_entries_of_a_tuple(pixel_scales):
147+
with pytest.raises(ValueError, match="pixel_scales"):
148+
aa.Array2D.no_mask(values=np.ones((5, 5)), pixel_scales=pixel_scales)
149+
150+
151+
@pytest.mark.parametrize("pixel_scales", [1, np.float64(1.0), np.int32(1)])
152+
def test__b6__control__an_integer_or_numpy_pixel_scale_builds_and_is_widened(
153+
pixel_scales,
154+
):
155+
"""
156+
The defect this closes: only an exact `float` used to be widened, so an `int` or a NumPy
157+
scalar was stored on the mask unconverted and every later use of it raised
158+
`TypeError: 'int' object is not subscriptable`.
159+
"""
160+
array = aa.Array2D.no_mask(values=np.ones((5, 5)), pixel_scales=pixel_scales)
161+
assert array.pixel_scales == (1.0, 1.0)
162+
assert array.pixel_scale == 1.0
163+
164+
mask = aa.Mask2D.circular(
165+
shape_native=(5, 5), radius=2.0, pixel_scales=pixel_scales
166+
)
167+
assert mask.pixel_scales == (1.0, 1.0)
168+
169+
grid = aa.Grid2D.uniform(shape_native=(5, 5), pixel_scales=pixel_scales)
170+
assert grid.pixel_scales == (1.0, 1.0)
171+
172+
173+
def test__b6__control__an_integer_pixel_scale_builds_a_1d_structure():
174+
array = aa.Array1D.no_mask(values=np.ones((5,)), pixel_scales=1)
175+
assert array.pixel_scales == (1.0,)
176+
177+
127178
# ======================================================================================
128179
# B7 — annulus radii must be ordered
129180
# ======================================================================================

0 commit comments

Comments
 (0)