Compare commits

...
Author SHA1 Message Date
Forgejo Actions
34075a7456 [docs] correct Yee calculus, source polarity, and phasor conventions 2026-10-28 17:49:32 -07:00
Forgejo Actions
247e3b0f73 [fdmath] fix dtype promotion, array-like unvectorization, and mirror bounds 2026-10-28 17:47:25 -07:00
10 changed files with 162 additions and 73 deletions

View file

@ -19,38 +19,39 @@ Submodules:
================================================================ ================================================================
From the "Frequency domain" section of `meanas.fdmath`, we have Following the "Frequency domain" section of `meanas.fdmath`, we use
$e^{+\imath \omega t}$ time dependence. Phasors are referenced to the
actual sample times of the staggered fields and sources:
$$ $$
\begin{aligned} \begin{aligned}
\tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{-\imath \omega l \Delta_t} \\ \tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{+\imath \omega l \Delta_t} \\
\tilde{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \tilde{H}_{\vec{r} + \frac{1}{2}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\ \hat{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \hat{H}_{\vec{r} + \frac{1}{2}} e^{+\imath \omega (l - \frac{1}{2}) \Delta_t} \\
\tilde{J}_{l, \vec{r}} &= \tilde{J}_{\vec{r}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \\ \tilde{J}_{l - \frac{1}{2}, \vec{r}} &= \tilde{J}_{\vec{r}} e^{+\imath \omega (l - \frac{1}{2}) \Delta_t} \\
\tilde{M}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &= \tilde{M}_{\vec{r} + \frac{1}{2}} e^{-\imath \omega l \Delta_t} \\ \hat{M}_{l, \vec{r} + \frac{1}{2}} &= \hat{M}_{\vec{r} + \frac{1}{2}} e^{+\imath \omega l \Delta_t} \\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
-\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} &= -\imath \Omega \tilde{J}_{\vec{r}} e^{\imath \omega \Delta_t / 2} \\
\Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t \Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t
\end{aligned} \end{aligned}
$$ $$
resulting in The difference multipliers for an integer-indexed sequence are
$$ $$
\begin{aligned} \begin{aligned}
\tilde{\partial}_t &\Rightarrow -\imath \Omega e^{-\imath \omega \Delta_t / 2}\\ \tilde{\partial}_t &\Rightarrow \imath \Omega e^{ \imath \omega \Delta_t / 2}\\
\hat{\partial}_t &\Rightarrow -\imath \Omega e^{ \imath \omega \Delta_t / 2}\\ \hat{\partial}_t &\Rightarrow \imath \Omega e^{-\imath \omega \Delta_t / 2}\\
\end{aligned} \end{aligned}
$$ $$
Maxwell's equations are then The half-step factors cancel when the staggered sample times are included.
Maxwell's equations for the phasors are then
$$ $$
\begin{aligned} \begin{aligned}
\tilde{\nabla} \times \tilde{E}_{\vec{r}} &= \tilde{\nabla} \times \tilde{E}_{\vec{r}} &=
\imath \Omega e^{-\imath \omega \Delta_t / 2} \hat{B}_{\vec{r} + \frac{1}{2}} -\imath \Omega \hat{B}_{\vec{r} + \frac{1}{2}}
- \hat{M}_{\vec{r} + \frac{1}{2}} \\ - \hat{M}_{\vec{r} + \frac{1}{2}} \\
\hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &= \hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &=
-\imath \Omega e^{ \imath \omega \Delta_t / 2} \tilde{D}_{\vec{r}} \imath \Omega \tilde{D}_{\vec{r}}
+ \tilde{J}_{\vec{r}} \\ + \tilde{J}_{\vec{r}} \\
\tilde{\nabla} \cdot \hat{B}_{\vec{r} + \frac{1}{2}} &= 0 \\ \tilde{\nabla} \cdot \hat{B}_{\vec{r} + \frac{1}{2}} &= 0 \\
\hat{\nabla} \cdot \tilde{D}_{\vec{r}} &= \rho_{\vec{r}} \hat{\nabla} \cdot \tilde{D}_{\vec{r}} &= \rho_{\vec{r}}
@ -61,13 +62,9 @@ With $\Delta_t \to 0$, this simplifies to
$$ $$
\begin{aligned} \begin{aligned}
\tilde{E}_{l, \vec{r}} &\to \tilde{E}_{\vec{r}} \\
\tilde{H}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &\to \tilde{H}_{\vec{r} + \frac{1}{2}} \\
\tilde{J}_{l, \vec{r}} &\to \tilde{J}_{\vec{r}} \\
\tilde{M}_{l - \frac{1}{2}, \vec{r} + \frac{1}{2}} &\to \tilde{M}_{\vec{r} + \frac{1}{2}} \\
\Omega &\to \omega \\ \Omega &\to \omega \\
\tilde{\partial}_t &\to -\imath \omega \\ \tilde{\partial}_t &\to \imath \omega \\
\hat{\partial}_t &\to -\imath \omega \\ \hat{\partial}_t &\to \imath \omega \\
\end{aligned} \end{aligned}
$$ $$
@ -76,14 +73,17 @@ and then
$$ $$
\begin{aligned} \begin{aligned}
\tilde{\nabla} \times \tilde{E}_{\vec{r}} &= \tilde{\nabla} \times \tilde{E}_{\vec{r}} &=
\imath \omega \hat{B}_{\vec{r} + \frac{1}{2}} -\imath \omega \hat{B}_{\vec{r} + \frac{1}{2}}
- \hat{M}_{\vec{r} + \frac{1}{2}} \\ - \hat{M}_{\vec{r} + \frac{1}{2}} \\
\hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &= \hat{\nabla} \times \hat{H}_{\vec{r} + \frac{1}{2}} &=
-\imath \omega \tilde{D}_{\vec{r}} \imath \omega \tilde{D}_{\vec{r}}
+ \tilde{J}_{\vec{r}} \\ + \tilde{J}_{\vec{r}} \\
\end{aligned} \end{aligned}
$$ $$
For zero magnetic current, eliminating $H$ gives the electric wave equation
used by `operators.e_full` and `functional.e_full`:
$$ $$
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}}) \hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
-\omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \omega \tilde{J}_{\vec{r}} \\ -\omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \omega \tilde{J}_{\vec{r}} \\

View file

@ -116,9 +116,9 @@ Expanding to three dimensions, we can define two gradients
$$ [\tilde{\nabla} f]_{m,n,p} = \vec{x} [\tilde{\partial}_x f]_{m + \frac{1}{2},n,p} + $$ [\tilde{\nabla} f]_{m,n,p} = \vec{x} [\tilde{\partial}_x f]_{m + \frac{1}{2},n,p} +
\vec{y} [\tilde{\partial}_y f]_{m,n + \frac{1}{2},p} + \vec{y} [\tilde{\partial}_y f]_{m,n + \frac{1}{2},p} +
\vec{z} [\tilde{\partial}_z f]_{m,n,p + \frac{1}{2}} $$ \vec{z} [\tilde{\partial}_z f]_{m,n,p + \frac{1}{2}} $$
$$ [\hat{\nabla} f]_{m,n,p} = \vec{x} [\hat{\partial}_x f]_{m + \frac{1}{2},n,p} + $$ [\hat{\nabla} f]_{m,n,p} = \vec{x} [\hat{\partial}_x f]_{m - \frac{1}{2},n,p} +
\vec{y} [\hat{\partial}_y f]_{m,n + \frac{1}{2},p} + \vec{y} [\hat{\partial}_y f]_{m,n - \frac{1}{2},p} +
\vec{z} [\hat{\partial}_z f]_{m,n,p + \frac{1}{2}} $$ \vec{z} [\hat{\partial}_z f]_{m,n,p - \frac{1}{2}} $$
or or
@ -232,7 +232,7 @@ The two curls are then
[\tilde{\nabla} \times \tilde{g}]_{m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2}} &= [\tilde{\nabla} \times \tilde{g}]_{m + \frac{1}{2}, n + \frac{1}{2}, p + \frac{1}{2}} &=
\vec{x} (\tilde{\partial}_y g^z_{m,n,p + \frac{1}{2}} - \tilde{\partial}_z g^y_{m,n + \frac{1}{2},p}) \\ \vec{x} (\tilde{\partial}_y g^z_{m,n,p + \frac{1}{2}} - \tilde{\partial}_z g^y_{m,n + \frac{1}{2},p}) \\
&+ \vec{y} (\tilde{\partial}_z g^x_{m + \frac{1}{2},n,p} - \tilde{\partial}_x g^z_{m,n,p + \frac{1}{2}}) \\ &+ \vec{y} (\tilde{\partial}_z g^x_{m + \frac{1}{2},n,p} - \tilde{\partial}_x g^z_{m,n,p + \frac{1}{2}}) \\
&+ \vec{z} (\tilde{\partial}_x g^y_{m,n + \frac{1}{2},p} - \tilde{\partial}_y g^z_{m + \frac{1}{2},n,p}) &+ \vec{z} (\tilde{\partial}_x g^y_{m,n + \frac{1}{2},p} - \tilde{\partial}_y g^x_{m + \frac{1}{2},n,p})
\end{aligned} $$ \end{aligned} $$
and and
@ -314,7 +314,7 @@ The time derivatives can be expanded to form the update equations:
[code: Maxwell's equations updates] [code: Maxwell's equations updates]
H[i, j, k] -= dt * (curl_forward(E)[i, j, k] + M[t, i, j, k]) / mu[i, j, k] H[i, j, k] -= dt * (curl_forward(E)[i, j, k] + M[t, i, j, k]) / mu[i, j, k]
E[i, j, k] += dt * (curl_back( H)[i, j, k] + J[t, i, j, k]) / epsilon[i, j, k] E[i, j, k] += dt * (curl_back( H)[i, j, k] - J[t, i, j, k]) / epsilon[i, j, k]
Note that the E-field fore-vector and H-field back-vector are offset by a half-cell, resulting Note that the E-field fore-vector and H-field back-vector are offset by a half-cell, resulting
in distinct locations for all six E- and H-field components: in distinct locations for all six E- and H-field components:
@ -384,7 +384,7 @@ $$
\begin{aligned} \begin{aligned}
\tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &= \tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &=
-\tilde{\partial}_t \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} -\tilde{\partial}_t \hat{B}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}}
- \hat{M}_{l-1, \vec{r} + \frac{1}{2}} \\ - \hat{M}_{l, \vec{r} + \frac{1}{2}} \\
\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &= \mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}} &=
-\tilde{\partial}_t \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\ -\tilde{\partial}_t \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &= \hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
@ -392,10 +392,10 @@ $$
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &= \hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
-\tilde{\partial}_t \hat{\nabla} \times \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\ -\tilde{\partial}_t \hat{\nabla} \times \hat{H}_{l-\frac{1}{2}, \vec{r} + \frac{1}{2}} \\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &= \hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) &=
-\tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \tilde{E}_{l, \vec{r}} + \hat{\partial}_t \tilde{J}_{l-\frac{1}{2},\vec{r}} \\ -\tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \tilde{E}_{l, \vec{r}} - \tilde{\partial}_t \tilde{J}_{l-\frac{1}{2},\vec{r}} \\
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}}) \hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{l,\vec{r}})
+ \tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \cdot \tilde{E}_{l, \vec{r}} + \tilde{\partial}_t \hat{\partial}_t \epsilon_{\vec{r}} \cdot \tilde{E}_{l, \vec{r}}
&= \tilde{\partial}_t \tilde{J}_{l - \frac{1}{2}, \vec{r}} &= -\tilde{\partial}_t \tilde{J}_{l - \frac{1}{2}, \vec{r}}
\end{aligned} \end{aligned}
$$ $$
@ -403,37 +403,42 @@ $$
Frequency domain Frequency domain
---------------- ----------------
We can substitute in a time-harmonic fields We use the $e^{+\imath \omega t}$ convention, matching the FDFD operators.
Substitute time-harmonic fields with phases referenced to their actual sample times:
$$ $$
\begin{aligned} \begin{aligned}
\tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{-\imath \omega l \Delta_t} \\ \tilde{E}_{l, \vec{r}} &= \tilde{E}_{\vec{r}} e^{+\imath \omega l \Delta_t} \\
\tilde{J}_{l, \vec{r}} &= \tilde{J}_{\vec{r}} e^{-\imath \omega (l - \frac{1}{2}) \Delta_t} \tilde{J}_{l-\frac{1}{2}, \vec{r}} &= \tilde{J}_{\vec{r}} e^{+\imath \omega (l - \frac{1}{2}) \Delta_t}
\end{aligned} \end{aligned}
$$ $$
resulting in For an integer-indexed sequence proportional to $e^{+\imath \omega l \Delta_t}$,
the forward and backward difference multipliers are
$$ $$
\begin{aligned} \begin{aligned}
\tilde{\partial}_t &\Rightarrow (e^{ \imath \omega \Delta_t} - 1) / \Delta_t = \frac{-2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{-\imath \omega \Delta_t / 2} = -\imath \Omega e^{-\imath \omega \Delta_t / 2}\\ \tilde{\partial}_t &\Rightarrow (e^{ \imath \omega \Delta_t} - 1) / \Delta_t = \frac{2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{ \imath \omega \Delta_t / 2} = \imath \Omega e^{ \imath \omega \Delta_t / 2}\\
\hat{\partial}_t &\Rightarrow (1 - e^{-\imath \omega \Delta_t}) / \Delta_t = \frac{-2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{ \imath \omega \Delta_t / 2} = -\imath \Omega e^{ \imath \omega \Delta_t / 2}\\ \hat{\partial}_t &\Rightarrow (1 - e^{-\imath \omega \Delta_t}) / \Delta_t = \frac{2 \imath}{\Delta_t} \sin(\omega \Delta_t / 2) e^{-\imath \omega \Delta_t / 2} = \imath \Omega e^{-\imath \omega \Delta_t / 2}\\
\Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t \Omega &= 2 \sin(\omega \Delta_t / 2) / \Delta_t
\end{aligned} \end{aligned}
$$ $$
This gives the frequency-domain wave equation, The half-step factors account for the shift in the derivative's sample time.
In particular, $\tilde{\partial}_t J_{l-\frac{1}{2}} = \imath \Omega J e^{\imath \omega l \Delta_t}$.
Thus, when the staggered fields are expressed using the phasors above, the
frequency-domain wave equation has no extra half-step phase:
$$ $$
\hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}}) \hat{\nabla} \times (\mu^{-1}_{\vec{r} + \frac{1}{2}} \cdot \tilde{\nabla} \times \tilde{E}_{\vec{r}})
-\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \Omega \tilde{J}_{\vec{r}} e^{\imath \omega \Delta_t / 2} \\ -\Omega^2 \epsilon_{\vec{r}} \cdot \tilde{E}_{\vec{r}} = -\imath \Omega \tilde{J}_{\vec{r}} \\
$$ $$
Plane waves and Dispersion relation Plane waves and Dispersion relation
------------------------------------ ------------------------------------
With uniform material distribution and no sources With uniform grid spacings, a uniform material distribution, and no sources
$$ $$
\begin{aligned} \begin{aligned}
@ -474,31 +479,31 @@ resulting in
$$ $$
\begin{aligned} \begin{aligned}
\tilde{\partial}_x &\Rightarrow (e^{ \imath k_x \Delta_x} - 1) / \Delta_t = \frac{-2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{ \imath k_x \Delta_x / 2} = \imath K_x e^{ \imath k_x \Delta_x / 2}\\ \tilde{\partial}_x &\Rightarrow (e^{ \imath k_x \Delta_x} - 1) / \Delta_x = \frac{2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{ \imath k_x \Delta_x / 2} = \imath K_x e^{ \imath k_x \Delta_x / 2}\\
\hat{\partial}_x &\Rightarrow (1 - e^{-\imath k_x \Delta_x}) / \Delta_t = \frac{-2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{-\imath k_x \Delta_x / 2} = \imath K_x e^{-\imath k_x \Delta_x / 2}\\ \hat{\partial}_x &\Rightarrow (1 - e^{-\imath k_x \Delta_x}) / \Delta_x = \frac{2 \imath}{\Delta_x} \sin(k_x \Delta_x / 2) e^{-\imath k_x \Delta_x / 2} = \imath K_x e^{-\imath k_x \Delta_x / 2}\\
K_x &= 2 \sin(k_x \Delta_x / 2) / \Delta_x \\ K_x &= 2 \sin(k_x \Delta_x / 2) / \Delta_x \\
\end{aligned} \end{aligned}
$$ $$
with similar expressions for the y and z dimnsions (and $K_y, K_z$). with similar expressions for the y and z dimensions (and $K_y, K_z$).
This implies This implies
$$ $$
\tilde{\nabla}^2 = -(K_x^2 + K_y^2 + K_z^2) \phi_{\vec{r}} \\ \tilde{\nabla}^2 \phi_{\vec{r}} = -(K_x^2 + K_y^2 + K_z^2) \phi_{\vec{r}} \\
K_x^2 + K_y^2 + K_z^2 = \Omega^2 \mu \epsilon = \Omega^2 / c^2 K_x^2 + K_y^2 + K_z^2 = \Omega^2 \mu \epsilon = \Omega^2 / c^2
$$ $$
where $c = \sqrt{\mu \epsilon}$. where $c = 1 / \sqrt{\mu \epsilon}$.
Assuming real $(k_x, k_y, k_z), \omega$ will be real only if To keep $\omega$ real for every real grid wavevector $(k_x, k_y, k_z)$,
we use the conservative stability bound
$$ c^2 \Delta_t^2 = \frac{\Delta_t^2}{\mu \epsilon} < 1/(\frac{1}{\Delta_x^2} + \frac{1}{\Delta_y^2} + \frac{1}{\Delta_z^2}) $$ $$ c^2 \Delta_t^2 = \frac{\Delta_t^2}{\mu \epsilon} < 1/(\frac{1}{\Delta_x^2} + \frac{1}{\Delta_y^2} + \frac{1}{\Delta_z^2}) $$
If $\Delta_x = \Delta_y = \Delta_z$, this simplifies to $c \Delta_t < \Delta_x / \sqrt{3}$. If $\Delta_x = \Delta_y = \Delta_z$, this simplifies to $c \Delta_t < \Delta_x / \sqrt{3}$.
This last form can be interpreted as enforcing causality; the distance that light This limits the distance that light travels in one timestep to less than
travels in one timestep (i.e., $c \Delta_t$) must be less than the diagonal $\Delta_x / \sqrt{3}$ on a uniform cubic grid.
of the smallest cell ( $\Delta_x / \sqrt{3}$ when on a uniform cubic grid).
Grid description Grid description

View file

@ -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])

View file

@ -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

View file

@ -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')

View file

@ -17,13 +17,13 @@ def maxwell_e(
""" """
Build a function which performs a portion the time-domain E-field update, Build a function which performs a portion the time-domain E-field update,
E += curl_back(H[t]) / epsilon E += dt * curl_back(H[t]) / epsilon
The full update should be The full update should be
E += (curl_back(H[t]) + J) / epsilon E += dt * (curl_back(H[t]) - J) / epsilon
which requires an additional step of `E += J / epsilon` which is not performed which requires an additional step of `E -= dt * J / epsilon` which is not performed
by the generated function. by the generated function.
See `meanas.fdmath` for descriptions of See `meanas.fdmath` for descriptions of
@ -72,13 +72,13 @@ def maxwell_h(
""" """
Build a function which performs part of the time-domain H-field update, Build a function which performs part of the time-domain H-field update,
H -= curl_forward(E[t]) / mu H -= dt * curl_forward(E[t]) / mu
The full update should be The full update should be
H -= (curl_forward(E[t]) + M) / mu H -= dt * (curl_forward(E[t]) + M) / mu
which requires an additional step of `H -= M / mu` which is not performed which requires an additional step of `H -= dt * M / mu` which is not performed
by the generated function; this step can be omitted if there is no magnetic by the generated function; this step can be omitted if there is no magnetic
current `M`. current `M`.

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) 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)

View file

@ -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)

View file

@ -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)

View file

@ -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]