[fdfd] correct backward mode signs, graded sensitivities, and flux precision

This commit is contained in:
Forgejo Actions 2026-10-28 18:14:22 -07:00
commit b52ee4f622
6 changed files with 89 additions and 8 deletions

View file

@ -226,6 +226,8 @@ def poynting_e_cross_h(dxes: dx_lists_t) -> Callable[[cfdfield, cfdfield], cfdfi
For time-average power, call it as `f(E, H.conj())` and take `Re(...) / 2`. For time-average power, call it as `f(E, H.conj())` and take `Re(...) / 2`.
""" """
def exh(e: cfdfield, h: cfdfield) -> cfdfield_t: def exh(e: cfdfield, h: cfdfield) -> cfdfield_t:
dtype = numpy.result_type(e, h, *dxes[0], *dxes[1])
e, h = numpy.asarray(e, dtype=dtype), numpy.asarray(h, dtype=dtype)
s = numpy.empty_like(e) s = numpy.empty_like(e)
ex = e[0] * dxes[0][0][:, None, None] ex = e[0] * dxes[0][0][:, None, None]
ey = e[1] * dxes[0][1][None, :, None] ey = e[1] * dxes[0][1][None, :, None]

View file

@ -836,7 +836,10 @@ def sensitivity(
= H_{yx}^\star \partial_{\epsilon_i}(A_E) E_{xy} + H_{yx}^\star A_E \partial_{\epsilon_i} E_{xy} = H_{yx}^\star \partial_{\epsilon_i}(A_E) E_{xy} + H_{yx}^\star A_E \partial_{\epsilon_i} E_{xy}
$$ $$
However, $H_{yx}^\star$ is actually a left-eigenvector of $A_E$. This can be verified by inspecting On a graded Yee grid, the left projection includes the quadrature weights:
$H_y^\star \Delta x_e \Delta y_h$ and $-H_x^\star \Delta x_h \Delta y_e$.
These weights multiply the left vector, before it acts on any derivative operators.
With these weights understood, $H_{yx}^\star$ is a left-eigenvector of $A_E$. This can be verified by inspecting
the form of `operator_h` ($A_H$) and comparing its conjugate transpose to `operator_e` ($A_E$). Also, note the form of `operator_h` ($A_H$) and comparing its conjugate transpose to `operator_e` ($A_E$). Also, note
$H_{yx}^\star \cdot E_{xy} = H^\star \times E$ recalls the mode orthogonality relation. See doi:10.5194/ars-9-85-201 $H_{yx}^\star \cdot E_{xy} = H^\star \times E$ recalls the mode orthogonality relation. See doi:10.5194/ars-9-85-201
for a similar approach. Therefore, for a similar approach. Therefore,
@ -883,11 +886,12 @@ def sensitivity(
mu_x, mu_y, _mu_z = numpy.split(mu, 3) mu_x, mu_y, _mu_z = numpy.split(mu, 3)
mu_yx = sparse.diags_array(numpy.hstack((mu_y, mu_x))) mu_yx = sparse.diags_array(numpy.hstack((mu_y, mu_x)))
da_exxhyy = vec(dxes[1][0][:, None] * dxes[0][1][None, :]) da_exxhyy = vec(dxes[0][0][:, None] * dxes[1][1][None, :])
da_eyyhxx = vec(dxes[1][1][None, :] * dxes[0][0][:, None]) da_eyyhxx = vec(dxes[1][0][:, None] * dxes[0][1][None, :])
ev_xy = numpy.concatenate(numpy.split(e_norm, 3)[:2]) * numpy.concatenate([da_exxhyy, da_eyyhxx]) ev_xy = numpy.concatenate(numpy.split(e_norm, 3)[:2])
hx, hy, hz = numpy.split(h_norm, 3) hx, hy, _hz = numpy.split(h_norm, 3)
hv_yx_conj = numpy.conj(numpy.concatenate([hy, -hx])) # The Yee-weighted H projection is the left eigenvector on a graded grid.
hv_yx_conj = numpy.conj(numpy.concatenate([hy, -hx])) * numpy.concatenate([da_exxhyy, da_eyyhxx])
sens_xy1 = (hv_yx_conj @ (omega * omega * mu_yx)) * ev_xy sens_xy1 = (hv_yx_conj @ (omega * omega * mu_yx)) * ev_xy
sens_xy2 = (hv_yx_conj @ sparse.vstack((Dfx, Dfy)) @ eps_z_inv @ sparse.hstack((Dbx, Dby))) * ev_xy sens_xy2 = (hv_yx_conj @ sparse.vstack((Dfx, Dfy)) @ eps_z_inv @ sparse.hstack((Dbx, Dby))) * ev_xy

View file

@ -161,8 +161,9 @@ def mode_from_2d(
e = unvec(ve, shape) e = unvec(ve, shape)
h = unvec(vh, shape) h = unvec(vh, shape)
# Adjust for propagation direction # Reversing beta changes Ez and transverse H, while Hz is unchanged.
h *= polarity # type: ignore # mypy issue with numpy e[2] *= polarity
h[:2] *= polarity
# Apply phase shift to H-field # Apply phase shift to H-field
h[:2] *= numpy.exp(-1j * polarity * 0.5 * wavenumber * dx_prop) h[:2] *= numpy.exp(-1j * polarity * 0.5 * wavenumber * dx_prop)

View file

@ -1,4 +1,5 @@
import numpy import numpy
import pytest
from ..fdmath import unvec, vec from ..fdmath import unvec, vec
from ..fdfd import functional, operators from ..fdfd import functional, operators
@ -120,3 +121,17 @@ def test_poynting_e_cross_h_matches_sparse_operator() -> None:
functional_result = functional.poynting_e_cross_h(DXES)(E_FIELD, H_FIELD) functional_result = functional.poynting_e_cross_h(DXES)(E_FIELD, H_FIELD)
assert_fields_match(functional_result, matrix_result) assert_fields_match(functional_result, matrix_result)
@pytest.mark.parametrize('wide_input', ['electric', 'magnetic', 'widths'])
def test_poynting_uses_promoted_precision_before_products(wide_input) -> None:
shape = (2, 2, 2)
e = numpy.zeros((3, *shape), dtype=numpy.complex128 if wide_input == 'electric' else numpy.complex64)
h = numpy.zeros((3, *shape), dtype=numpy.complex128 if wide_input == 'magnetic' else numpy.complex64)
e[1], h[2] = 1e20, 1e20j
widths = [[numpy.ones(n, dtype=numpy.float64 if wide_input == 'widths' else numpy.float32) for n in shape] for _ in range(2)]
with numpy.errstate(over='raise', invalid='raise'):
result = functional.poynting_e_cross_h(widths)(e, h)
expected = unvec(operators.poynting_e_cross(vec(e.astype(complex)), widths) @ vec(h.astype(complex)), shape)
assert result.dtype == numpy.complex128
assert_fields_close(result, expected, atol=0, rtol=1e-14)

View file

@ -1,6 +1,7 @@
import numpy import numpy
from numpy.linalg import norm from numpy.linalg import norm
from numpy.testing import assert_allclose from numpy.testing import assert_allclose
from scipy import linalg
from ..fdmath import vec from ..fdmath import vec
from ..fdfd import waveguide_2d from ..fdfd import waveguide_2d
@ -120,6 +121,33 @@ def test_waveguide_2d_sensitivity_matches_finite_difference() -> None:
assert 0.4 < ratio < 1.8 assert 0.4 < ratio < 1.8
def test_sensitivity_matches_central_differences_on_graded_yee_mesh() -> None:
rng = numpy.random.default_rng(4)
shape = (4, 3)
omega = 0.7
epsilon = vec(1 + rng.uniform(0, 3, (3, *shape)))
magnetic_widths = [rng.uniform(0.2, 1.2, n) for n in shape]
electric_widths = [0.5 * (dx + numpy.roll(dx, -1)) for dx in magnetic_widths]
dxes = [electric_widths, magnetic_widths]
values, vectors = linalg.eig(waveguide_2d.operator_e(omega, dxes, epsilon).toarray())
selected = numpy.argmax(values.real)
beta = numpy.sqrt(values[selected])
e, h = waveguide_2d.normalized_fields_e(vectors[:, selected], beta, omega, dxes, epsilon)
sensitivity = waveguide_2d.sensitivity(e, h, beta, omega, dxes, epsilon)
delta = 1e-5
finite_difference = numpy.empty(epsilon.size, dtype=complex)
for index in range(epsilon.size):
roots = []
for direction in (-1, 1):
perturbed = epsilon.copy()
perturbed[index] += direction * delta
roots_squared = linalg.eigvals(waveguide_2d.operator_e(omega, dxes, perturbed).toarray())
candidates = numpy.sqrt(roots_squared)
roots.append(candidates[numpy.argmin(numpy.abs(candidates - beta))])
finite_difference[index] = (roots[1] - roots[0]) / (2 * delta)
assert_allclose(sensitivity, finite_difference, atol=1e-8, rtol=1e-5)
def test_waveguide_2d_normalized_fields_h_are_finite_and_unit_normalized_with_mu() -> None: def test_waveguide_2d_normalized_fields_h_are_finite_and_unit_normalized_with_mu() -> None:
epsilon = build_asymmetric_epsilon() epsilon = build_asymmetric_epsilon()
mu = build_mu_profile() mu = build_mu_profile()

View file

@ -2,17 +2,48 @@ import contextlib
import io import io
import numpy import numpy
from numpy.linalg import norm from numpy.linalg import norm
from scipy import linalg
import pytest import pytest
import warnings import warnings
from ..fdmath import vec, unvec from ..fdmath import vec, unvec
from ..fdmath.operators import deriv_forward, deriv_back from ..fdmath.operators import deriv_forward, deriv_back
from ..fdmath.functional import curl_forward, curl_back
from ..fdfd import waveguide_2d, waveguide_3d, waveguide_cyl from ..fdfd import waveguide_2d, waveguide_3d, waveguide_cyl
OMEGA = 1 / 1500 OMEGA = 1 / 1500
@pytest.mark.parametrize('axis', [0, 1, 2])
@pytest.mark.parametrize('polarity', [-1, 1])
def test_embedded_hybrid_mode_obeys_both_maxwell_curls(axis, polarity) -> None:
omega = 0.7
# Cyclic rotation preserves the orientation of the vector cross product.
permutation = numpy.roll(numpy.arange(3), axis + 1)
canonical = numpy.ones((3, 4, 3, 9))
canonical[:, 1:3, :, :] = 4
epsilon = canonical[permutation].transpose(0, *(permutation + 1))
shape = epsilon.shape[1:]
dxes = [[numpy.full(n, 0.4) for n in shape] for _ in range(2)]
cross_dxes = [[numpy.full(4, 0.4), numpy.full(3, 0.4)] for _ in range(2)]
values, vectors = linalg.eig(waveguide_2d.operator_e(omega, cross_dxes, vec(canonical[:, :, :, 0])).toarray())
selected = numpy.argmax(values.real)
slices = [slice(None)] * 3
slices[axis] = slice(4, 5)
mode = waveguide_3d.mode_from_2d(
vectors[:, selected], numpy.sqrt(values[selected]), omega=omega, dxes=dxes,
axis=axis, polarity=polarity, slices=slices, epsilon=epsilon,
)
e, h = [waveguide_3d.expand_e(mode[field], mode['wavenumber'], dxes, axis, polarity, slices) for field in ('E', 'H')]
assert norm(h[axis]) > 0.01 * norm(h)
region = [slice(None)] * 4
region[axis + 1] = slice(1, -1)
ce, ch = curl_forward(dxes[0])(e), curl_back(dxes[1])(h)
assert relative_residual(ce[tuple(region)], (-1j * omega * h)[tuple(region)]) < 1e-11
assert relative_residual(ch[tuple(region)], (1j * omega * epsilon * e)[tuple(region)]) < 1e-11
def build_waveguide_3d_mode( def build_waveguide_3d_mode(
*, *,
slice_start: int, slice_start: int,