[fdmath] fix dtype promotion, array-like unvectorization, and mirror bounds

This commit is contained in:
Forgejo Actions 2026-10-28 17:47:25 -07:00
commit 247e3b0f73
7 changed files with 104 additions and 20 deletions

View file

@ -3,7 +3,6 @@ Math functions for finite difference simulations
Basic discrete calculus etc.
"""
from typing import TypeVar
from collections.abc import Sequence, Callable
import numpy
@ -32,14 +31,15 @@ def _derivative(axis: int, forward: bool, widths: NDArray | None) -> fdfield_upd
raise ValueError('out must not alias the field or widths')
if not output.flags.writeable or not numpy.can_cast(dtype, output.dtype, casting='same_kind'):
raise ValueError('out must be writable with a compatible dtype')
dtype = numpy.result_type(dtype, output.dtype)
low, high = [slice(None)] * 3, [slice(None)] * 3
low[axis], high[axis] = slice(None, -1), slice(1, None)
first, last = [slice(None)] * 3, [slice(None)] * 3
first[axis], last[axis] = slice(0, 1), slice(-1, None)
destination = low if forward else high
numpy.subtract(f[tuple(high)], f[tuple(low)], out=output[tuple(destination)])
numpy.subtract(f[tuple(high)], f[tuple(low)], out=output[tuple(destination)], dtype=dtype)
destination = last if forward else first
numpy.subtract(f[tuple(first)], f[tuple(last)], out=output[tuple(destination)])
numpy.subtract(f[tuple(first)], f[tuple(last)], out=output[tuple(destination)], dtype=dtype)
if widths is not None:
expand = [1, 1, 1]
expand[axis] = len(widths)
@ -55,6 +55,7 @@ def deriv_forward(
Each callable accepts ``derivative(field, *, out=None)``. An output buffer
must match the field shape and must not alias the field or width arrays.
Subtraction uses the dtype promoted by the field, widths, and output buffer.
Slice-based subtraction avoids a full-size roll temporary.
"""
return tuple(_derivative(axis, True, None if dx_e is None else dx_e[axis]) for axis in range(3))
@ -70,12 +71,9 @@ def deriv_back(
return tuple(_derivative(axis, False, None if dx_h is None else dx_h[axis]) for axis in range(3))
TT = TypeVar('TT', bound='NDArray[floating | complexfloating]')
def curl_forward(
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
) -> Callable[[TT], TT]:
) -> Callable[[NDArray[floating | complexfloating]], NDArray[floating | complexfloating]]:
r"""
Curl operator for use with the E field.
@ -85,12 +83,14 @@ def curl_forward(
Returns:
Function `f` for taking the discrete forward curl of a field,
`f(E)` -> curlE $= \nabla_f \times E$
`f(E)` -> curlE $= \nabla_f \times E$.
The output dtype is promoted from the field and cell widths.
"""
Dx, Dy, Dz = deriv_forward(dx_e)
def ce_fun(e: TT) -> TT:
output = numpy.empty_like(e)
def ce_fun(e: NDArray[floating | complexfloating]) -> NDArray[floating | complexfloating]:
dtype = e.dtype if dx_e is None else numpy.result_type(e, *dx_e)
output = numpy.empty_like(e, dtype=dtype)
output[0] = Dy(e[2])
output[1] = Dz(e[0])
output[2] = Dx(e[1])
@ -104,7 +104,7 @@ def curl_forward(
def curl_back(
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
) -> Callable[[TT], TT]:
) -> Callable[[NDArray[floating | complexfloating]], NDArray[floating | complexfloating]]:
r"""
Create a function which takes the backward curl of a field.
@ -114,12 +114,14 @@ def curl_back(
Returns:
Function `f` for taking the discrete backward curl of a field,
`f(H)` -> curlH $= \nabla_b \times H$
`f(H)` -> curlH $= \nabla_b \times H$.
The output dtype is promoted from the field and cell widths.
"""
Dx, Dy, Dz = deriv_back(dx_h)
def ch_fun(h: TT) -> TT:
output = numpy.empty_like(h)
def ch_fun(h: NDArray[floating | complexfloating]) -> NDArray[floating | complexfloating]:
dtype = h.dtype if dx_h is None else numpy.result_type(h, *dx_h)
output = numpy.empty_like(h, dtype=dtype)
output[0] = Dy(h[2])
output[1] = Dz(h[0])
output[2] = Dx(h[1])

View file

@ -61,6 +61,9 @@ def shift_with_mirror(
Utility operator for performing an n-element shift along a specified axis, with mirror
boundary conditions applied to the cells beyond the receding edge.
The absolute shift distance may not exceed the axis length. A shift by the
full axis length reverses that axis; shifts on a singleton axis are identities.
Args:
axis: Axis to shift along. x=0, y=1, z=2
shape: Shape of the grid being shifted
@ -73,8 +76,8 @@ def shift_with_mirror(
raise Exception(f'Invalid shape: {shape}')
if axis not in range(len(shape)):
raise Exception(f'Invalid direction: {axis}, shape is {shape}')
if shift_distance >= shape[axis]:
raise Exception(f'Shift ({shift_distance}) is too large for axis {axis} of size {shape[axis]}')
if abs(shift_distance) > shape[axis]:
raise ValueError(f'Shift ({shift_distance}) is too large for axis {axis} of size {shape[axis]}')
def mirrored_range(n: int, s: int) -> NDArray[numpy.int_]:
v = numpy.arange(n) + s

View file

@ -113,7 +113,7 @@ def unvec(
Returns `None` if called with `v=None`.
Args:
v: 1D ndarray representing a vector field of shape shape (or None)
v: 1D array-like representing a vector field of shape shape (or None)
shape: shape of the vector field
nvdim: Number of components in each vector
@ -122,4 +122,4 @@ def unvec(
"""
if v is None:
return None
return v.reshape((nvdim, *shape), order='C') # type: ignore
return numpy.asarray(v).reshape((nvdim, *shape), order='C')

View file

@ -62,6 +62,55 @@ def test_curls_match_sparse_operators_on_nonuniform_grid() -> None:
assert_fields_close(curl_back, matrix_back, atol=1e-12, rtol=1e-12)
@pytest.mark.parametrize('name', ['curl_forward', 'curl_back'])
@pytest.mark.parametrize('field_dtype', [numpy.float32, numpy.complex64])
@pytest.mark.parametrize('complex_widths', [False, True])
def test_curls_promote_field_dtype_and_match_sparse_operators(name, field_dtype, complex_widths) -> None:
field = VECTOR_FIELD.astype(field_dtype) if numpy.issubdtype(field_dtype, numpy.complexfloating) else VECTOR_FIELD.real.astype(field_dtype)
widths = [dx * (1 + 0.5j) if complex_widths else dx for dx in DX_E]
result = getattr(fd_functional, name)(widths)(field)
expected = unvec(getattr(fd_operators, name)(widths) @ vec(field), SHAPE)
assert result.dtype == numpy.result_type(field, *widths)
assert_fields_close(result, expected, atol=1e-12, rtol=1e-12)
@pytest.mark.parametrize('name', ['curl_forward', 'curl_back'])
def test_curls_preserve_small_residual_between_large_derivative_terms(name) -> None:
shape = (1, 2, 2)
field = numpy.zeros((3, *shape), dtype=numpy.float32)
field[2, :, 1, :] = 1e8
field[1, :, :, 1] = 1e8
widths = [numpy.ones(1), numpy.full(2, 1e8 / (1e8 + 1)), numpy.ones(2)]
result = getattr(fd_functional, name)(widths)(field)
expected = unvec(getattr(fd_operators, name)(widths) @ vec(field), shape)
assert abs(expected[0, 0, 0, 0]) == pytest.approx(1)
assert_fields_close(result, expected, atol=1e-7, rtol=1e-12)
@pytest.mark.parametrize('name', ['deriv_forward', 'deriv_back'])
@pytest.mark.parametrize('axis', [0, 1, 2])
@pytest.mark.parametrize('spaced', [False, True])
def test_derivatives_promote_subtraction_before_overflow(name, axis, spaced) -> None:
shape = [1, 1, 1]
shape[axis] = 2
field = numpy.array([3e38, -3e38], dtype=numpy.float32).reshape(shape)
widths = [numpy.full(n, 1e38) for n in shape] if spaced else None
derivative = getattr(fd_functional, name)(widths)[axis]
wide_field = field.astype(numpy.float64)
if name == 'deriv_forward':
expected = numpy.roll(wide_field, -1, axis=axis) - wide_field
else:
expected = wide_field - numpy.roll(wide_field, 1, axis=axis)
if spaced:
expected /= 1e38
with numpy.errstate(over='raise', invalid='raise'):
if spaced:
assert_close(derivative(field), expected)
output = numpy.empty(shape, dtype=numpy.float64)
assert derivative(field, out=output) is output
assert_close(output, expected)
@pytest.mark.parametrize('periodic', [True, False])
def test_gridlock_spacings_differentiate_physical_coordinates(periodic) -> None:
grid = Grid([[0, 1, 3, 7, 15], [-2, 0, 5, 6], [3, 6, 10]], periodic=periodic)

View file

@ -56,7 +56,8 @@ def test_shift_circ_rejects_invalid_arguments(args: tuple[int, tuple[int, ...],
[
((0, (2,), 1), 'Invalid shape'),
((3, SHAPE, 1), 'Invalid direction'),
((0, SHAPE, SHAPE[0]), 'too large'),
((0, SHAPE, SHAPE[0] + 1), 'too large'),
((0, SHAPE, -SHAPE[0] - 1), 'too large'),
],
)
def test_shift_with_mirror_rejects_invalid_arguments(args: tuple[int, tuple[int, ...], int], message: str) -> None:
@ -64,6 +65,23 @@ def test_shift_with_mirror_rejects_invalid_arguments(args: tuple[int, tuple[int,
operators.shift_with_mirror(*args)
@pytest.mark.parametrize('axis', [0, 1, 2])
@pytest.mark.parametrize('polarity', [-1, 1])
def test_shift_with_mirror_by_full_axis_length_reverses_axis(axis, polarity) -> None:
result = _apply_scalar_matrix(operators.shift_with_mirror(axis, SHAPE, polarity * SHAPE[axis]))
assert_close(result, numpy.flip(SCALAR_FIELD, axis=axis))
@pytest.mark.parametrize('axis', [0, 1, 2])
@pytest.mark.parametrize('shift_distance', [-1, 0, 1])
def test_shift_with_mirror_on_singleton_axis_is_identity(axis, shift_distance) -> None:
shape = list(SHAPE)
shape[axis] = 1
field = numpy.arange(numpy.prod(shape)).reshape(shape)
result = (operators.shift_with_mirror(axis, shape, shift_distance) @ field.ravel()).reshape(shape)
assert_close(result, field)
def test_vec_cross_matches_pointwise_cross_product() -> None:
matrix_result = unvec(operators.vec_cross(vec(VECTOR_LEFT)) @ vec(VECTOR_RIGHT), SHAPE)
expected = numpy.empty_like(VECTOR_LEFT)

View file

@ -1,4 +1,5 @@
import numpy
import pytest
from ..fdmath import unvec, vec
from ._test_builders import complex_ramp, real_ramp
@ -44,3 +45,11 @@ def test_unvec_with_two_components_round_trips_vector() -> None:
def test_vec_accepts_arraylike_input() -> None:
arraylike = [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
assert_close(vec(arraylike), numpy.ravel(arraylike, order='C'))
@pytest.mark.parametrize('arraylike_type', [list, tuple])
def test_unvec_accepts_arraylike_input(arraylike_type) -> None:
vector = numpy.arange(3 * numpy.prod(SHAPE)) * (1 + 0.5j)
field = unvec(arraylike_type(vector), SHAPE)
assert field.shape == (3, *SHAPE)
assert_close(vec(field), vector)

View file

@ -289,7 +289,10 @@ def test_slice_derivatives_match_periodic_reference_and_output_buffers(dtype, ax
f = np.ascontiguousarray(f)
widths = [np.linspace(.7, 1.3, n) for n in f.shape] if spaced else None
derivative = (deriv_forward if forward else deriv_back)(widths)[axis]
expected = np.roll(f, -1, axis=axis) - f if forward else f - np.roll(f, 1, axis=axis)
# Widths promote the calculation before subtraction, including integer fields.
calculation_field = f.astype(np.result_type(f, widths[axis])) if spaced else f
expected = (np.roll(calculation_field, -1, axis=axis) - calculation_field if forward
else calculation_field - np.roll(calculation_field, 1, axis=axis))
if spaced:
shape = [1, 1, 1]
shape[axis] = f.shape[axis]