meanas/meanas/test/test_waveguide_mode_helpers.py

613 lines
21 KiB
Python

import contextlib
import io
import numpy
from numpy.linalg import norm
from scipy import linalg
import pytest
import warnings
from ..fdmath import vec, unvec
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
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)
rng = numpy.random.default_rng(4)
canonical = numpy.repeat((1 + rng.uniform(0, 3, (3, 4, 3)))[..., None], 9, axis=3)
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(e[axis]) > 0.01 * norm(e)
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(
*,
slice_start: int,
polarity: int,
axis: int = 0,
shape: tuple[int, int, int] = (5, 5, 1),
dxes: list[list[numpy.ndarray]] | None = None,
) -> tuple[numpy.ndarray, list[list[numpy.ndarray]], tuple[slice, slice, slice], waveguide_3d.Waveguide3DMode]:
epsilon = numpy.ones((3, *shape), dtype=float)
if dxes is None:
dxes = [[numpy.ones(size) for size in shape] for _ in range(2)]
slices_l = [slice(None), slice(None), slice(None)]
slices_l[axis] = slice(slice_start, slice_start + 1)
slices = tuple(slices_l)
result = waveguide_3d.solve_mode(
0,
omega=OMEGA,
dxes=dxes,
axis=axis,
polarity=polarity,
slices=slices,
epsilon=epsilon,
)
return epsilon, dxes, slices, result
def build_waveguide_cyl_fixture(
*,
nonuniform: bool = False,
asymmetric: bool = False,
) -> tuple[list[list[numpy.ndarray]], numpy.ndarray, float]:
if nonuniform:
dxes = [
[numpy.array([1.0, 1.5, 1.2, 0.8, 1.1]), numpy.ones(5)],
[numpy.array([0.9, 1.4, 1.0, 0.7, 1.2]), numpy.ones(5)],
]
else:
dxes = [[numpy.ones(5), numpy.ones(5)] for _ in range(2)]
epsilon_3d = numpy.ones((3, 5, 5), dtype=float)
if asymmetric:
epsilon_3d[:, 2, 1] = 2.0
epsilon = vec(epsilon_3d)
return dxes, epsilon, 10.0
def build_waveguide_cyl_mu_profile() -> numpy.ndarray:
return numpy.linspace(1.5, 2.2, 3 * 5 * 5)
def relative_residual(lhs: numpy.ndarray, rhs: numpy.ndarray) -> float:
return float(norm(lhs - rhs) / (norm(lhs) + norm(rhs)))
@pytest.mark.parametrize(
('axis', 'shape'),
[
(0, (5, 5, 1)),
(1, (5, 5, 1)),
(2, (5, 5, 5)),
],
)
def test_waveguide_3d_solve_mode_and_expand_e_are_phase_consistent(
axis: int,
shape: tuple[int, int, int],
) -> None:
polarity = 1
dxes = [
[
numpy.linspace(0.9 + 0.1 * eh + 0.05 * aa, 1.2 + 0.1 * eh + 0.05 * aa, size)
for aa, size in enumerate(shape)
]
for eh in range(2)
]
epsilon, dxes, slices, result = build_waveguide_3d_mode(
slice_start=2,
polarity=polarity,
axis=axis,
shape=shape,
dxes=dxes,
)
expanded = waveguide_3d.expand_e(
E=result['E'],
wavenumber=result['wavenumber'],
dxes=dxes,
axis=axis,
polarity=polarity,
slices=slices,
)
dx_prop = 0.5 * numpy.array([dx[axis][slices[axis]] for dx in dxes]).sum()
expected_wavenumber = 2 / dx_prop * numpy.arcsin(result['wavenumber_2d'] * dx_prop / 2)
solved_slice = (slice(None), *slices)
assert result['E'].shape == epsilon.shape
assert result['H'].shape == epsilon.shape
assert numpy.isfinite(result['E']).all()
assert numpy.isfinite(result['H']).all()
assert abs(result['wavenumber'] - expected_wavenumber) < 1e-12
assert numpy.allclose(expanded[solved_slice], result['E'][solved_slice])
component, *indices = numpy.unravel_index(
numpy.abs(result['E']).argmax(),
result['E'].shape,
)
line_selection: list[int | slice] = list(indices)
line_selection[axis] = slice(None)
values = expanded[component][tuple(line_selection)]
ratios = values[1:] / values[:-1]
intervals = numpy.real(dxes[1][axis][1:] if component == axis else dxes[0][axis][:-1])
expected_ratios = numpy.exp(-1j * result['wavenumber'] * intervals)
numpy.testing.assert_allclose(ratios, expected_ratios, rtol=1e-6, atol=1e-9)
@pytest.mark.parametrize(
('polarity', 'expected_range'),
[(1, (0, 1)), (-1, (3, 4))],
)
def test_waveguide_3d_compute_overlap_e_uses_adjacent_window(
polarity: int,
expected_range: tuple[int, int],
) -> None:
_epsilon, dxes, slices, result = build_waveguide_3d_mode(slice_start=2, polarity=polarity)
with warnings.catch_warnings(record=True) as caught:
overlap = waveguide_3d.compute_overlap_e(
E=result['E'],
wavenumber=result['wavenumber'],
dxes=dxes,
axis=0,
polarity=polarity,
slices=slices,
)
nonzero = numpy.argwhere(numpy.abs(overlap) > 0)
assert not caught
assert numpy.isfinite(overlap).all()
assert nonzero[:, 1].min() == expected_range[0]
assert nonzero[:, 1].max() == expected_range[1]
expanded = waveguide_3d.expand_e(
E=result['E'],
wavenumber=result['wavenumber'],
dxes=dxes,
axis=0,
polarity=polarity,
slices=slices,
)
numpy.testing.assert_allclose(numpy.sum(overlap * expanded.conj()), 1.0, atol=1e-12)
numpy.testing.assert_allclose(vec(expanded) @ vec(overlap).conj(), 1.0, atol=1e-12)
@pytest.mark.parametrize(
('polarity', 'slice_start', 'expected_index'),
[(1, 1, 0), (-1, 3, 4)],
)
def test_waveguide_3d_compute_overlap_e_warns_when_window_is_clipped(
polarity: int,
slice_start: int,
expected_index: int,
) -> None:
_epsilon, dxes, slices, result = build_waveguide_3d_mode(slice_start=slice_start, polarity=polarity)
with pytest.warns(RuntimeWarning, match='clipped'):
overlap = waveguide_3d.compute_overlap_e(
E=result['E'],
wavenumber=result['wavenumber'],
dxes=dxes,
axis=0,
polarity=polarity,
slices=slices,
)
nonzero = numpy.argwhere(numpy.abs(overlap) > 0)
assert numpy.isfinite(overlap).all()
assert nonzero[:, 1].min() == expected_index
assert nonzero[:, 1].max() == expected_index
@pytest.mark.parametrize(
('polarity', 'slice_start'),
[(1, 0), (-1, 4)],
)
def test_waveguide_3d_compute_overlap_e_rejects_empty_overlap_window(
polarity: int,
slice_start: int,
) -> None:
_epsilon, dxes, slices, result = build_waveguide_3d_mode(slice_start=slice_start, polarity=polarity)
with pytest.raises(ValueError, match='outside the domain'):
waveguide_3d.compute_overlap_e(
E=result['E'],
wavenumber=result['wavenumber'],
dxes=dxes,
axis=0,
polarity=polarity,
slices=slices,
)
def test_waveguide_3d_compute_overlap_e_rejects_zero_support_window() -> None:
_epsilon, dxes, slices, result = build_waveguide_3d_mode(slice_start=2, polarity=1)
with pytest.raises(ValueError, match='no overlap field support'):
waveguide_3d.compute_overlap_e(
E=numpy.zeros_like(result['E']),
wavenumber=result['wavenumber'],
dxes=dxes,
axis=0,
polarity=1,
slices=slices,
)
@pytest.mark.parametrize('use_mu', [False, True])
def test_waveguide_cyl_solved_modes_are_ordered_and_low_residual(use_mu: bool) -> None:
dxes, epsilon, rmin = build_waveguide_cyl_fixture(asymmetric=use_mu)
mu = build_waveguide_cyl_mu_profile() if use_mu else None
e_xys, angular_wavenumbers = waveguide_cyl.solve_modes(
[0, 1],
omega=OMEGA,
dxes=dxes,
epsilon=epsilon,
rmin=rmin,
mu=mu,
)
operator = waveguide_cyl.cylindrical_operator(OMEGA, dxes, epsilon, rmin=rmin, mu=mu)
assert numpy.all(numpy.diff(numpy.real(angular_wavenumbers)) <= 0)
for e_xy, angular_wavenumber in zip(e_xys, angular_wavenumbers, strict=True):
eigenvalue = (angular_wavenumber / rmin) ** 2
residual = norm(operator @ e_xy - eigenvalue * e_xy) / norm(e_xy)
assert residual < 1e-6
def test_waveguide_cyl_linear_wavenumbers_are_finite_and_ordered() -> None:
dxes, epsilon, rmin = build_waveguide_cyl_fixture()
e_xys, angular_wavenumbers = waveguide_cyl.solve_modes(
[0, 1],
omega=OMEGA,
dxes=dxes,
epsilon=epsilon,
rmin=10.0,
)
linear_wavenumbers = waveguide_cyl.linear_wavenumbers(
e_xys,
angular_wavenumbers,
epsilon=epsilon,
dxes=dxes,
rmin=rmin,
)
assert numpy.isfinite(linear_wavenumbers).all()
assert numpy.all(numpy.real(linear_wavenumbers) > 0)
def test_waveguide_cyl_dxes2t_matches_expected_radius_scaling() -> None:
dxes, _epsilon, rmin = build_waveguide_cyl_fixture(nonuniform=True)
Ta, Tb = waveguide_cyl.dxes2T(dxes, rmin)
ta = (rmin + numpy.cumsum(dxes[0][0])) / rmin
tb = (
rmin
+ dxes[0][0] / 2
+ numpy.concatenate((numpy.zeros(1), numpy.cumsum(dxes[1][0][:-1])))
) / rmin
numpy.testing.assert_allclose(Ta.diagonal(), numpy.repeat(ta, dxes[0][1].size))
numpy.testing.assert_allclose(Tb.diagonal(), numpy.repeat(tb, dxes[1][1].size))
@pytest.mark.parametrize('use_mu', [False, True])
def test_waveguide_cyl_operator_matches_2d_limit(use_mu: bool) -> None:
dxes, epsilon, _rmin = build_waveguide_cyl_fixture(asymmetric=True)
mu = build_waveguide_cyl_mu_profile() if use_mu else None
rmin = 1e15
cyl_operator = waveguide_cyl.cylindrical_operator(OMEGA, dxes, epsilon, rmin=rmin, mu=mu)
straight_operator = waveguide_2d.operator_e(OMEGA, dxes, epsilon, mu=mu)
numpy.testing.assert_allclose(
cyl_operator.toarray(),
straight_operator.toarray(),
rtol=1e-9,
atol=1e-10,
)
@pytest.mark.parametrize('use_mu', [False, True])
def test_waveguide_cyl_reconstruction_matches_2d_limit(use_mu: bool) -> None:
dxes, epsilon, _rmin = build_waveguide_cyl_fixture(asymmetric=True)
mu = build_waveguide_cyl_mu_profile() if use_mu else None
rmin = 1e15
e_xy, wavenumber = waveguide_2d.solve_mode(
0,
omega=OMEGA,
dxes=dxes,
epsilon=epsilon,
mu=mu,
)
angular_wavenumber = wavenumber * rmin
cyl_e = waveguide_cyl.exy2e(
angular_wavenumber=angular_wavenumber,
dxes=dxes,
rmin=rmin,
epsilon=epsilon,
) @ e_xy
straight_e = waveguide_2d.exy2e(
wavenumber=wavenumber,
dxes=dxes,
epsilon=epsilon,
) @ e_xy
cyl_h = waveguide_cyl.e2h(
angular_wavenumber=angular_wavenumber,
omega=OMEGA,
dxes=dxes,
rmin=rmin,
mu=mu,
) @ cyl_e
straight_h = waveguide_2d.e2h(
wavenumber=wavenumber,
omega=OMEGA,
dxes=dxes,
mu=mu,
) @ straight_e
numpy.testing.assert_allclose(cyl_e, straight_e, rtol=1e-8, atol=1e-8)
numpy.testing.assert_allclose(cyl_h, straight_h, rtol=1e-8, atol=1e-8)
@pytest.mark.parametrize('use_mu', [False, True])
def test_waveguide_cyl_reconstruction_is_finite_radius_consistent(use_mu: bool) -> None:
dxes, epsilon, rmin = build_waveguide_cyl_fixture(nonuniform=True, asymmetric=True)
mu = build_waveguide_cyl_mu_profile() if use_mu else None
mu_full = numpy.ones_like(epsilon) if mu is None else mu
e_xy, angular_wavenumber = waveguide_cyl.solve_mode(
0,
omega=OMEGA,
dxes=dxes,
epsilon=epsilon,
rmin=rmin,
mu=mu,
)
e_field = waveguide_cyl.exy2e(
angular_wavenumber=angular_wavenumber,
dxes=dxes,
rmin=rmin,
epsilon=epsilon,
) @ e_xy
h_field = waveguide_cyl.exy2h(
angular_wavenumber=angular_wavenumber,
omega=OMEGA,
dxes=dxes,
rmin=rmin,
epsilon=epsilon,
mu=mu,
) @ e_xy
Dfx, Dfy = deriv_forward(dxes[0])
Dbx, Dby = deriv_back(dxes[1])
Ta, Tb = waveguide_cyl.dxes2T(dxes, rmin)
ta = Ta.diagonal()
tb = Tb.diagonal()
beta = angular_wavenumber / rmin
er, ey, ez = numpy.split(e_field, 3)
hr, hy, hz = numpy.split(h_field, 3)
eps_r, eps_y, eps_z = numpy.split(epsilon, 3)
mu_r, mu_y, mu_z = numpy.split(mu_full, 3)
ez_rhs = (
(Dbx @ (tb * eps_r * er))
+ (Dby @ (ta * eps_y * ey))
) / eps_z
hr_rhs = 1j * beta * ey / ta + Dfy @ ez
hy_rhs = -1j * beta * er / tb - (Dfx @ (ta * ez)) / tb
hz_rhs = -Dfy @ er + Dfx @ ey
assert relative_residual(1j * beta * ez, ez_rhs) < 1e-7
assert relative_residual(-1j * OMEGA * mu_r * hr, hr_rhs) < 1e-7
assert relative_residual(-1j * OMEGA * mu_y * hy, hy_rhs) < 1e-7
assert relative_residual(-1j * OMEGA * mu_z * hz, hz_rhs) < 1e-7
def test_waveguide_cyl_linear_wavenumbers_use_component_radii() -> None:
dxes, epsilon, rmin = build_waveguide_cyl_fixture(nonuniform=True)
nx = dxes[0][0].size
ny = dxes[0][1].size
angular_wavenumber = numpy.array([2.0])
ra = rmin + numpy.cumsum(dxes[0][0])
rb = (
rmin
+ dxes[0][0] / 2
+ numpy.concatenate((numpy.zeros(1), numpy.cumsum(dxes[1][0][:-1])))
)
er_only = numpy.zeros((1, 2 * nx * ny), dtype=complex)
er_only[0] = vec(numpy.array([numpy.ones((nx, ny)), numpy.zeros((nx, ny))]))
ey_only = numpy.zeros_like(er_only)
ey_only[0] = vec(numpy.array([numpy.zeros((nx, ny)), numpy.ones((nx, ny))]))
er_linear = waveguide_cyl.linear_wavenumbers(
er_only,
angular_wavenumber,
epsilon=epsilon,
dxes=dxes,
rmin=rmin,
)
ey_linear = waveguide_cyl.linear_wavenumbers(
ey_only,
angular_wavenumber,
epsilon=epsilon,
dxes=dxes,
rmin=rmin,
)
numpy.testing.assert_allclose(er_linear[0], angular_wavenumber[0] / numpy.average(rb, weights=dxes[0][0]))
numpy.testing.assert_allclose(ey_linear[0], angular_wavenumber[0] / numpy.average(ra, weights=dxes[1][0]))
@pytest.mark.parametrize('use_mu', [False, True])
def test_cylindrical_modes_obey_the_full_ampere_curl(use_mu) -> None:
shape, omega, rmin = (4, 3), 0.7, 2.0
dxes = [[numpy.full(n, 0.4) for n in shape] for _ in range(2)]
rng = numpy.random.default_rng(4)
epsilon = vec(1 + rng.uniform(0, 3, (3, *shape)))
mu = vec(1 + rng.uniform(0, 2, (3, *shape))) if use_mu else numpy.ones_like(epsilon)
matrix = waveguide_cyl.cylindrical_operator(omega, dxes, epsilon, rmin, mu).toarray()
values, vectors = linalg.eig(matrix)
selected = numpy.argmax(values.real)
beta = numpy.sqrt(values[selected])
electric = waveguide_cyl.exy2e(beta * rmin, dxes, rmin, epsilon) @ vectors[:, selected]
magnetic = waveguide_cyl.e2h(beta * rmin, omega, dxes, rmin, mu) @ electric
ta, tb = (metric.diagonal() for metric in waveguide_cyl.dxes2T(dxes, rmin))
dbx, dby = deriv_back(dxes[1])
hr, hy, hz = numpy.split(magnetic, 3)
# Physical cylindrical Ampere curl, independently of electric reconstruction.
curl_h = numpy.concatenate((
1j * beta * hy / tb + dby @ hz,
-1j * beta * hr / ta - (dbx @ (tb * hz)) / ta,
-dby @ hr + dbx @ hy,
))
numpy.testing.assert_allclose(curl_h, 1j * omega * epsilon * electric, atol=1e-11, rtol=1e-10)
def test_cylindrical_mean_radius_is_invariant_under_constant_density_refinement() -> None:
for widths in ([1.0, 3.0], [1.0, 1.0, 1.0, 1.0]):
n = len(widths)
dxes = [[numpy.array(widths), numpy.ones(1)] for _ in range(2)]
electric_xy = numpy.concatenate((numpy.ones(n), numpy.zeros(n)))[None, :].astype(complex)
beta = waveguide_cyl.linear_wavenumbers(electric_xy, [12.0], numpy.ones(3 * n), dxes, rmin=10.0)
numpy.testing.assert_allclose(beta, 1.0)
@pytest.mark.parametrize('axis', [0, 1, 2])
@pytest.mark.parametrize('polarity', [-1, 1])
def test_graded_expansion_uses_each_components_actual_yee_intervals(axis, polarity) -> None:
shape = [1, 1, 1]
shape[axis] = 4
dxes = [[numpy.ones(n) for n in shape] for _ in range(2)]
dxes[0][axis] = numpy.array([1.0, 2.0, 3.0, 4.0])
dxes[1][axis] = numpy.array([0.6, 0.8, 1.0, 1.2])
source = [slice(None)] * 3
source[axis] = slice(1, 2)
field = numpy.zeros((3, *shape), dtype=complex)
field[(slice(None), *source)] = 1 + 0.5j
expanded = waveguide_3d.expand_e(field, 0.2, dxes, axis, polarity, source)
for component in range(3):
line = expanded[component].ravel()
intervals = dxes[1][axis][1:] if component == axis else dxes[0][axis][:-1]
numpy.testing.assert_allclose(line[1:] / line[:-1], numpy.exp(-1j * polarity * 0.2 * intervals))
numpy.testing.assert_array_equal(expanded[(slice(None), *source)], field[(slice(None), *source)])
@pytest.mark.parametrize('beta', [2.0, 3.0])
def test_grid_band_edge_rejects_power_normalization_and_selects_decay(beta) -> None:
shape = (1, 1, 5)
dxes = [[numpy.ones(n) for n in shape] for _ in range(2)]
kwargs = dict(omega=1.0, dxes=dxes, axis=2, polarity=1,
slices=(slice(None), slice(None), slice(0, 1)), epsilon=numpy.full((3, *shape), beta ** 2))
with pytest.raises(ValueError, match='grid band edge'):
waveguide_3d.mode_from_2d(numpy.array([1, 0], dtype=complex), beta, **kwargs)
mode = waveguide_3d.mode_from_2d(numpy.array([1, 0], dtype=complex), beta, normalization='lorentz', **kwargs)
assert mode['wavenumber'].imag <= 0
expanded = waveguide_3d.expand_e(mode['E'], mode['wavenumber'], dxes, 2, 1, kwargs['slices'])
ratio = abs(expanded[0, 0, 0, 1] / expanded[0, 0, 0, 0])
assert ratio <= 1 + 1e-14
if beta > 2:
assert ratio < 1
def test_waveguide_cyl_exy2e_and_exy2h_return_finite_full_fields() -> None:
dxes, epsilon, rmin = build_waveguide_cyl_fixture()
mu = build_waveguide_cyl_mu_profile()
e_xy, angular_wavenumber = waveguide_cyl.solve_mode(
0,
omega=OMEGA,
dxes=dxes,
epsilon=epsilon,
rmin=rmin,
mu=mu,
)
e_field = waveguide_cyl.exy2e(
angular_wavenumber=angular_wavenumber,
dxes=dxes,
rmin=rmin,
epsilon=epsilon,
) @ e_xy
h_field = waveguide_cyl.exy2h(
angular_wavenumber=angular_wavenumber,
omega=OMEGA,
dxes=dxes,
rmin=rmin,
epsilon=epsilon,
mu=mu,
) @ e_xy
assert e_field.shape == (3 * 25,)
assert h_field.shape == (3 * 25,)
assert numpy.isfinite(e_field).all()
assert numpy.isfinite(h_field).all()
assert unvec(e_field, (5, 5)).shape == (3, 5, 5)
assert unvec(h_field, (5, 5)).shape == (3, 5, 5)
@pytest.mark.parametrize('use_mu', [False, True])
def test_waveguide_cyl_normalized_fields_are_unit_norm_and_silent(use_mu: bool) -> None:
dxes, epsilon, rmin = build_waveguide_cyl_fixture()
mu = build_waveguide_cyl_mu_profile() if use_mu else None
e_xy, angular_wavenumber = waveguide_cyl.solve_mode(
0,
omega=OMEGA,
dxes=dxes,
epsilon=epsilon,
rmin=rmin,
mu=mu,
)
output = io.StringIO()
with contextlib.redirect_stdout(output):
e_field, h_field = waveguide_cyl.normalized_fields_e(
e_xy,
angular_wavenumber=angular_wavenumber,
omega=OMEGA,
dxes=dxes,
rmin=rmin,
epsilon=epsilon,
mu=mu,
)
overlap = waveguide_2d.inner_product(e_field, h_field, dxes, conj_h=True)
assert output.getvalue() == ''
assert numpy.isfinite(e_field).all()
assert numpy.isfinite(h_field).all()
assert abs(overlap.real - 1.0) < 1e-10
assert abs(overlap.imag) < 1e-10