Skip to content

Commit f795c3b

Browse files
committed
Fix complex interpolation and normalized period edge cases
1 parent 538d39b commit f795c3b

3 files changed

Lines changed: 156 additions & 26 deletions

File tree

‎src/array_api_extra/_agnostic/_interpolation.py‎

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,39 @@ def _safe_divide(
5959
return xp.where(overflow & ~positive_overflow, -infinity, out)
6060

6161

62+
def _safe_multiply(
63+
x1: Array, x2: Array, /, *, zero_times_infinity: float, xp: ArrayNamespace
64+
) -> Array:
65+
"""Multiply arrays without warnings from overflow or zero times infinity."""
66+
zero = xp.zeros_like(x1)
67+
one = xp.ones_like(x2)
68+
maximum = xp.asarray(
69+
xp.finfo(x1.dtype).max, dtype=x1.dtype, device=_compat.device(x1)
70+
)
71+
absolute_x1 = xp.abs(x1)
72+
absolute_x2 = xp.abs(x2)
73+
large_x2 = absolute_x2 > 1
74+
overflow_threshold = maximum / xp.where(large_x2, absolute_x2, one)
75+
overflow = (
76+
xp.isfinite(x1)
77+
& xp.isfinite(x2)
78+
& large_x2
79+
& (absolute_x1 > overflow_threshold)
80+
)
81+
zero_inf = ((x1 == 0) & xp.isinf(x2)) | (xp.isinf(x1) & (x2 == 0))
82+
suppressed = overflow | zero_inf
83+
84+
out = xp.where(suppressed, zero, x1) * xp.where(suppressed, zero, x2)
85+
infinity = xp.asarray(math.inf, dtype=x1.dtype, device=_compat.device(x1))
86+
positive_overflow = overflow & ((x1 > 0) == (x2 > 0))
87+
out = xp.where(positive_overflow, infinity, out)
88+
out = xp.where(overflow & ~positive_overflow, -infinity, out)
89+
zero_inf_value = xp.asarray(
90+
zero_times_infinity, dtype=x1.dtype, device=_compat.device(x1)
91+
)
92+
return xp.where(zero_inf, zero_inf_value, out)
93+
94+
6295
def _interp_component(
6396
x: Array,
6497
x_lo: Array,
@@ -68,6 +101,7 @@ def _interp_component(
68101
/,
69102
*,
70103
inactive: Array,
104+
reciprocal_first: bool = False,
71105
xp: ArrayNamespace,
72106
) -> Array:
73107
"""Interpolate one real component without invalid arithmetic."""
@@ -87,17 +121,28 @@ def _interp_component(
87121
coordinate_difference = _safe_difference(safe_x_hi, safe_x_lo, xp=xp)
88122
value_difference = _safe_difference(safe_y_hi, safe_y_lo, xp=xp)
89123
indeterminate_slope = xp.isinf(coordinate_difference) & xp.isinf(value_difference)
90-
slope = _safe_divide(
91-
xp.where(
92-
indeterminate_slope, xp.zeros_like(value_difference), value_difference
93-
),
94-
xp.where(
95-
indeterminate_slope,
96-
xp.ones_like(coordinate_difference),
97-
coordinate_difference,
98-
),
99-
xp=xp,
124+
safe_value_difference = xp.where(
125+
indeterminate_slope, xp.zeros_like(value_difference), value_difference
126+
)
127+
safe_coordinate_difference = xp.where(
128+
indeterminate_slope,
129+
xp.ones_like(coordinate_difference),
130+
coordinate_difference,
100131
)
132+
if reciprocal_first:
133+
inverse_coordinate_difference = _safe_divide(
134+
xp.ones_like(safe_coordinate_difference),
135+
safe_coordinate_difference,
136+
xp=xp,
137+
)
138+
slope = _safe_multiply(
139+
safe_value_difference,
140+
inverse_coordinate_difference,
141+
zero_times_infinity=math.nan,
142+
xp=xp,
143+
)
144+
else:
145+
slope = _safe_divide(safe_value_difference, safe_coordinate_difference, xp=xp)
101146
slope = xp.where(indeterminate_slope, nan, slope)
102147

103148
left_delta = _safe_difference(safe_x, safe_x_lo, xp=xp)
@@ -131,13 +176,22 @@ def _interp_component(
131176

132177
only_lo_coordinate_infinite = xp.isinf(x_lo) & ~xp.isinf(x_hi)
133178
only_hi_coordinate_infinite = ~xp.isinf(x_lo) & xp.isinf(x_hi)
179+
finite_y_lo = xp.where(values_finite, y_lo, xp.zeros_like(y_lo))
180+
finite_y_hi = xp.where(values_finite, y_hi, xp.zeros_like(y_hi))
181+
infinite_coordinate_value_overflow = values_finite & xp.isinf(
182+
_safe_difference(finite_y_hi, finite_y_lo, xp=xp)
183+
)
134184
infinite_coordinate_out = xp.where(
135-
values_finite & only_lo_coordinate_infinite,
136-
y_hi,
185+
infinite_coordinate_value_overflow,
186+
nan,
137187
xp.where(
138-
values_finite & only_hi_coordinate_infinite,
139-
y_lo,
140-
xp.where(equal_values, y_lo, nan),
188+
values_finite & only_lo_coordinate_infinite,
189+
y_hi,
190+
xp.where(
191+
values_finite & only_hi_coordinate_infinite,
192+
y_lo,
193+
xp.where(equal_values, y_lo, nan),
194+
),
141195
),
142196
)
143197
return xp.where(coordinate_infinite, infinite_coordinate_out, out)
@@ -234,6 +288,7 @@ def interp(
234288
y_lo_real,
235289
y_hi_real,
236290
inactive=inactive,
291+
reciprocal_first=True,
237292
xp=xp,
238293
)
239294
out_imag = _interp_component(
@@ -243,6 +298,7 @@ def interp(
243298
y_lo_imag,
244299
y_hi_imag,
245300
inactive=inactive,
301+
reciprocal_first=True,
246302
xp=xp,
247303
)
248304
left_array = values[0] if left is None else left

‎src/array_api_extra/_interpolation.py‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,10 @@ def interp(
148148
the last element of `values` is used. Ignored, without validation, when
149149
`period` is provided.
150150
period : real scalar, optional
151-
Finite period for the sample and query coordinates. A negative value is
152-
treated as its absolute value and zero is invalid. When provided,
153-
coordinates are normalized to the period and samples are sorted by
154-
normalized coordinate.
151+
Period for the sample and query coordinates. It must remain finite and
152+
nonzero when converted to ``float64``. A negative value is treated as its
153+
absolute value. When provided, coordinates are normalized to the period
154+
and samples are sorted by normalized coordinate.
155155
xp : array_namespace, optional
156156
The standard-compatible namespace for the array arguments. Default: infer.
157157
@@ -197,13 +197,17 @@ def interp(
197197
if not _is_real_scalar(period):
198198
msg = "`period` must be a finite real scalar or None."
199199
raise TypeError(msg)
200+
try:
201+
period = float(abs(period))
202+
except (OverflowError, ValueError) as error:
203+
msg = "`period` must be representable as a finite float."
204+
raise ValueError(msg) from error
200205
if not math.isfinite(period):
201-
msg = "`period` must be finite."
206+
msg = "`period` must be finite after conversion to float."
202207
raise ValueError(msg)
203208
if period == 0:
204-
msg = "`period` must be nonzero."
209+
msg = "`period` must be nonzero after conversion to float."
205210
raise ValueError(msg)
206-
period = float(abs(period))
207211

208212
namespace_args: list[Array] = [x_points, values]
209213
if _compat.is_array_api_obj(x):

‎tests/main/test_interpolation.py‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from fractions import Fraction
12
from typing import Any, Literal
23

34
import numpy as np
@@ -151,13 +152,16 @@ def test_reference_extreme_finite_coordinates(
151152
)
152153
assert_equal(actual, xp.asarray(expected, dtype=xp.float64))
153154

155+
@pytest.mark.parametrize("values", [[0.0, 1.0], [0.0, 1e-320]])
154156
@implementations
155157
def test_reference_subnormal_interval(
156-
self, xp: ArrayNamespace, implementation: Implementation
158+
self,
159+
xp: ArrayNamespace,
160+
implementation: Implementation,
161+
values: list[float],
157162
):
158163
x_points = np.asarray([0.0, 1e-320])
159164
x = np.asarray([5e-321])
160-
values = np.asarray([0.0, 1.0])
161165
expected = np.interp(x, x_points, values)
162166

163167
actual = _interp(
@@ -169,6 +173,67 @@ def test_reference_subnormal_interval(
169173
)
170174
assert_equal(actual, xp.asarray(expected, dtype=xp.float64))
171175

176+
@pytest.mark.parametrize(
177+
"end_value", [complex(1e-320, 1e-320), complex(1e-320, 0.0)]
178+
)
179+
@implementations
180+
def test_reference_subnormal_complex_slope(
181+
self,
182+
xp: ArrayNamespace,
183+
implementation: Implementation,
184+
end_value: complex,
185+
):
186+
x_points = np.asarray([0.0, 1e-320])
187+
x = np.asarray([5e-321])
188+
values = np.asarray([0j, end_value])
189+
expected = np.interp(x, x_points, values)
190+
191+
actual = _interp(
192+
implementation,
193+
xp.asarray(x, dtype=xp.float64),
194+
xp.asarray(x_points, dtype=xp.float64),
195+
xp.asarray(values, dtype=xp.complex128),
196+
left=0,
197+
xp=xp,
198+
)
199+
assert_equal(xp.real(actual), xp.asarray(expected.real, dtype=xp.float64))
200+
assert_equal(xp.imag(actual), xp.asarray(expected.imag, dtype=xp.float64))
201+
202+
@pytest.mark.parametrize(
203+
("x_points", "x"),
204+
[([-np.inf, 0.0], [-1.0]), ([0.0, np.inf], [1.0])],
205+
ids=["left", "right"],
206+
)
207+
@pytest.mark.parametrize("complex_values", [False, True], ids=["real", "complex"])
208+
@implementations
209+
def test_reference_infinite_coordinate_value_overflow(
210+
self,
211+
xp: ArrayNamespace,
212+
implementation: Implementation,
213+
x_points: list[float],
214+
x: list[float],
215+
complex_values: bool,
216+
):
217+
values = np.asarray([-1e308, 1e308])
218+
dtype = xp.float64
219+
if complex_values:
220+
values = values + values * 1j
221+
dtype = xp.complex128
222+
expected = np.interp(x, x_points, values)
223+
224+
actual = _interp(
225+
implementation,
226+
xp.asarray(x, dtype=xp.float64),
227+
xp.asarray(x_points, dtype=xp.float64),
228+
xp.asarray(values, dtype=dtype),
229+
xp=xp,
230+
)
231+
if complex_values:
232+
assert_equal(xp.real(actual), xp.asarray(expected.real, dtype=xp.float64))
233+
assert_equal(xp.imag(actual), xp.asarray(expected.imag, dtype=xp.float64))
234+
else:
235+
assert_equal(actual, xp.asarray(expected, dtype=xp.float64))
236+
172237
# NumPy recommends strictly increasing sample coordinates. array-api-extra
173238
# deliberately defines these repeated-knot cases, including which value wins at
174239
# the exact knot, rather than exposing searchsorted clipping or a 0/0 division.
@@ -239,7 +304,10 @@ def test_complex_nan_query(
239304
xp.asarray(values, dtype=xp.complex128),
240305
xp=xp,
241306
)
242-
assert_equal(actual, xp.asarray(expected, dtype=xp.complex128))
307+
assert_equal(xp.real(actual), xp.asarray(expected.real, dtype=xp.float64))
308+
assert_equal(xp.imag(actual), xp.asarray(expected.imag, dtype=xp.float64))
309+
with pytest.raises(AssertionError):
310+
assert_equal(xp.imag(actual), xp.asarray([123.0], dtype=xp.float64))
243311

244312
@implementations
245313
def test_left_right_and_complex_values(
@@ -426,9 +494,11 @@ def test_bound_and_period_validation(self, xp: ArrayNamespace):
426494
with pytest.raises(TypeError):
427495
_ = xpx_interp(x, x_points, values, left=1j)
428496

429-
for period in (0.0, np.inf, -np.inf, np.nan):
497+
for period in (0.0, np.inf, -np.inf, np.nan, Fraction(1, 10**400)):
430498
with pytest.raises(ValueError, match=r"must be (finite|nonzero)"):
431499
_ = xpx_interp(x, x_points, values, period=period)
500+
with pytest.raises(ValueError, match="representable as a finite float"):
501+
_ = xpx_interp(x, x_points, values, period=Fraction(10**400, 1))
432502
with pytest.raises(TypeError):
433503
_ = xpx_interp(x, x_points, values, period=True)
434504
with pytest.raises(TypeError):

0 commit comments

Comments
 (0)