From 247e3b0f73dce854313e2ddb169a25572aa6c861 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Wed, 28 Oct 2026 17:47:25 -0700 Subject: [PATCH] [fdmath] fix dtype promotion, array-like unvectorization, and mirror bounds --- meanas/fdmath/functional.py | 30 ++++++++------- meanas/fdmath/operators.py | 7 +++- meanas/fdmath/vectorization.py | 4 +- meanas/test/test_fdmath_functional.py | 49 ++++++++++++++++++++++++ meanas/test/test_fdmath_operators.py | 20 +++++++++- meanas/test/test_fdmath_vectorization.py | 9 +++++ meanas/test/test_numerical_feedback.py | 5 ++- 7 files changed, 104 insertions(+), 20 deletions(-) diff --git a/meanas/fdmath/functional.py b/meanas/fdmath/functional.py index dacde37..c48848a 100644 --- a/meanas/fdmath/functional.py +++ b/meanas/fdmath/functional.py @@ -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]) diff --git a/meanas/fdmath/operators.py b/meanas/fdmath/operators.py index 8b7cabc..d9348ba 100644 --- a/meanas/fdmath/operators.py +++ b/meanas/fdmath/operators.py @@ -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 diff --git a/meanas/fdmath/vectorization.py b/meanas/fdmath/vectorization.py index 77b722f..2a47fd8 100644 --- a/meanas/fdmath/vectorization.py +++ b/meanas/fdmath/vectorization.py @@ -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') diff --git a/meanas/test/test_fdmath_functional.py b/meanas/test/test_fdmath_functional.py index f6d1491..3a1f3cf 100644 --- a/meanas/test/test_fdmath_functional.py +++ b/meanas/test/test_fdmath_functional.py @@ -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) diff --git a/meanas/test/test_fdmath_operators.py b/meanas/test/test_fdmath_operators.py index 9b1dec0..2ba7755 100644 --- a/meanas/test/test_fdmath_operators.py +++ b/meanas/test/test_fdmath_operators.py @@ -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) diff --git a/meanas/test/test_fdmath_vectorization.py b/meanas/test/test_fdmath_vectorization.py index c55f7cd..a895d24 100644 --- a/meanas/test/test_fdmath_vectorization.py +++ b/meanas/test/test_fdmath_vectorization.py @@ -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) diff --git a/meanas/test/test_numerical_feedback.py b/meanas/test/test_numerical_feedback.py index 1658bb7..6c2d27b 100644 --- a/meanas/test/test_numerical_feedback.py +++ b/meanas/test/test_numerical_feedback.py @@ -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]