[fdmath] fix dtype promotion, array-like unvectorization, and mirror bounds
This commit is contained in:
parent
282ed2c38b
commit
247e3b0f73
7 changed files with 104 additions and 20 deletions
|
|
@ -3,7 +3,6 @@ Math functions for finite difference simulations
|
||||||
|
|
||||||
Basic discrete calculus etc.
|
Basic discrete calculus etc.
|
||||||
"""
|
"""
|
||||||
from typing import TypeVar
|
|
||||||
from collections.abc import Sequence, Callable
|
from collections.abc import Sequence, Callable
|
||||||
|
|
||||||
import numpy
|
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')
|
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'):
|
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')
|
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, high = [slice(None)] * 3, [slice(None)] * 3
|
||||||
low[axis], high[axis] = slice(None, -1), slice(1, None)
|
low[axis], high[axis] = slice(None, -1), slice(1, None)
|
||||||
first, last = [slice(None)] * 3, [slice(None)] * 3
|
first, last = [slice(None)] * 3, [slice(None)] * 3
|
||||||
first[axis], last[axis] = slice(0, 1), slice(-1, None)
|
first[axis], last[axis] = slice(0, 1), slice(-1, None)
|
||||||
destination = low if forward else high
|
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
|
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:
|
if widths is not None:
|
||||||
expand = [1, 1, 1]
|
expand = [1, 1, 1]
|
||||||
expand[axis] = len(widths)
|
expand[axis] = len(widths)
|
||||||
|
|
@ -55,6 +55,7 @@ def deriv_forward(
|
||||||
|
|
||||||
Each callable accepts ``derivative(field, *, out=None)``. An output buffer
|
Each callable accepts ``derivative(field, *, out=None)``. An output buffer
|
||||||
must match the field shape and must not alias the field or width arrays.
|
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.
|
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))
|
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))
|
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(
|
def curl_forward(
|
||||||
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
|
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
|
||||||
) -> Callable[[TT], TT]:
|
) -> Callable[[NDArray[floating | complexfloating]], NDArray[floating | complexfloating]]:
|
||||||
r"""
|
r"""
|
||||||
Curl operator for use with the E field.
|
Curl operator for use with the E field.
|
||||||
|
|
||||||
|
|
@ -85,12 +83,14 @@ def curl_forward(
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Function `f` for taking the discrete forward curl of a field,
|
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)
|
Dx, Dy, Dz = deriv_forward(dx_e)
|
||||||
|
|
||||||
def ce_fun(e: TT) -> TT:
|
def ce_fun(e: NDArray[floating | complexfloating]) -> NDArray[floating | complexfloating]:
|
||||||
output = numpy.empty_like(e)
|
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[0] = Dy(e[2])
|
||||||
output[1] = Dz(e[0])
|
output[1] = Dz(e[0])
|
||||||
output[2] = Dx(e[1])
|
output[2] = Dx(e[1])
|
||||||
|
|
@ -104,7 +104,7 @@ def curl_forward(
|
||||||
|
|
||||||
def curl_back(
|
def curl_back(
|
||||||
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
|
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
|
||||||
) -> Callable[[TT], TT]:
|
) -> Callable[[NDArray[floating | complexfloating]], NDArray[floating | complexfloating]]:
|
||||||
r"""
|
r"""
|
||||||
Create a function which takes the backward curl of a field.
|
Create a function which takes the backward curl of a field.
|
||||||
|
|
||||||
|
|
@ -114,12 +114,14 @@ def curl_back(
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Function `f` for taking the discrete backward curl of a field,
|
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)
|
Dx, Dy, Dz = deriv_back(dx_h)
|
||||||
|
|
||||||
def ch_fun(h: TT) -> TT:
|
def ch_fun(h: NDArray[floating | complexfloating]) -> NDArray[floating | complexfloating]:
|
||||||
output = numpy.empty_like(h)
|
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[0] = Dy(h[2])
|
||||||
output[1] = Dz(h[0])
|
output[1] = Dz(h[0])
|
||||||
output[2] = Dx(h[1])
|
output[2] = Dx(h[1])
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ def shift_with_mirror(
|
||||||
Utility operator for performing an n-element shift along a specified axis, 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.
|
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:
|
Args:
|
||||||
axis: Axis to shift along. x=0, y=1, z=2
|
axis: Axis to shift along. x=0, y=1, z=2
|
||||||
shape: Shape of the grid being shifted
|
shape: Shape of the grid being shifted
|
||||||
|
|
@ -73,8 +76,8 @@ def shift_with_mirror(
|
||||||
raise Exception(f'Invalid shape: {shape}')
|
raise Exception(f'Invalid shape: {shape}')
|
||||||
if axis not in range(len(shape)):
|
if axis not in range(len(shape)):
|
||||||
raise Exception(f'Invalid direction: {axis}, shape is {shape}')
|
raise Exception(f'Invalid direction: {axis}, shape is {shape}')
|
||||||
if shift_distance >= shape[axis]:
|
if abs(shift_distance) > shape[axis]:
|
||||||
raise Exception(f'Shift ({shift_distance}) is too large for axis {axis} of size {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_]:
|
def mirrored_range(n: int, s: int) -> NDArray[numpy.int_]:
|
||||||
v = numpy.arange(n) + s
|
v = numpy.arange(n) + s
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ def unvec(
|
||||||
Returns `None` if called with `v=None`.
|
Returns `None` if called with `v=None`.
|
||||||
|
|
||||||
Args:
|
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
|
shape: shape of the vector field
|
||||||
nvdim: Number of components in each vector
|
nvdim: Number of components in each vector
|
||||||
|
|
||||||
|
|
@ -122,4 +122,4 @@ def unvec(
|
||||||
"""
|
"""
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
return v.reshape((nvdim, *shape), order='C') # type: ignore
|
return numpy.asarray(v).reshape((nvdim, *shape), order='C')
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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])
|
@pytest.mark.parametrize('periodic', [True, False])
|
||||||
def test_gridlock_spacings_differentiate_physical_coordinates(periodic) -> None:
|
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)
|
grid = Grid([[0, 1, 3, 7, 15], [-2, 0, 5, 6], [3, 6, 10]], periodic=periodic)
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ def test_shift_circ_rejects_invalid_arguments(args: tuple[int, tuple[int, ...],
|
||||||
[
|
[
|
||||||
((0, (2,), 1), 'Invalid shape'),
|
((0, (2,), 1), 'Invalid shape'),
|
||||||
((3, SHAPE, 1), 'Invalid direction'),
|
((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:
|
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)
|
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:
|
def test_vec_cross_matches_pointwise_cross_product() -> None:
|
||||||
matrix_result = unvec(operators.vec_cross(vec(VECTOR_LEFT)) @ vec(VECTOR_RIGHT), SHAPE)
|
matrix_result = unvec(operators.vec_cross(vec(VECTOR_LEFT)) @ vec(VECTOR_RIGHT), SHAPE)
|
||||||
expected = numpy.empty_like(VECTOR_LEFT)
|
expected = numpy.empty_like(VECTOR_LEFT)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import numpy
|
import numpy
|
||||||
|
import pytest
|
||||||
|
|
||||||
from ..fdmath import unvec, vec
|
from ..fdmath import unvec, vec
|
||||||
from ._test_builders import complex_ramp, real_ramp
|
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:
|
def test_vec_accepts_arraylike_input() -> None:
|
||||||
arraylike = [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
|
arraylike = [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
|
||||||
assert_close(vec(arraylike), numpy.ravel(arraylike, order='C'))
|
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)
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,10 @@ def test_slice_derivatives_match_periodic_reference_and_output_buffers(dtype, ax
|
||||||
f = np.ascontiguousarray(f)
|
f = np.ascontiguousarray(f)
|
||||||
widths = [np.linspace(.7, 1.3, n) for n in f.shape] if spaced else None
|
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]
|
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:
|
if spaced:
|
||||||
shape = [1, 1, 1]
|
shape = [1, 1, 1]
|
||||||
shape[axis] = f.shape[axis]
|
shape[axis] = f.shape[axis]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue