Compare commits

..

No commits in common. "cd3deb87fbdd63a2b861aaa05fd8fe9044edd200" and "bf99f35f9b0e9ce1632eec2efe13581ebff2b983" have entirely different histories.

8 changed files with 92 additions and 975 deletions

View file

@ -172,8 +172,6 @@ The tracked examples under `examples/` are the intended entry points for users:
- `examples/eme_bend.py`: straight-to-bent waveguide mode matching with - `examples/eme_bend.py`: straight-to-bent waveguide mode matching with
cylindrical bend modes, interface scattering, and a cascaded bend-network cylindrical bend modes, interface scattering, and a cascaded bend-network
example built with `scikit-rf`. example built with `scikit-rf`.
- `examples/eme_taper_cmt.py`: sampled cross-section local-mode CMT for a
continuously varying rib-waveguide taper.
- `examples/fdfd.py`: direct frequency-domain waveguide excitation and overlap / - `examples/fdfd.py`: direct frequency-domain waveguide excitation and overlap /
Poynting analysis without a time-domain run. Poynting analysis without a time-domain run.

View file

@ -28,8 +28,6 @@ Relevant starting examples:
scattering between two nearby waveguide cross-sections scattering between two nearby waveguide cross-sections
- `examples/eme_bend.py` for straight-to-bent mode matching with cylindrical - `examples/eme_bend.py` for straight-to-bent mode matching with cylindrical
bend modes and a cascaded bend-network example bend modes and a cascaded bend-network example
- `examples/eme_taper_cmt.py` for local-mode CMT through sampled continuously
varying taper cross-sections
- `examples/fdfd.py` for direct frequency-domain waveguide excitation - `examples/fdfd.py` for direct frequency-domain waveguide excitation
For solver equivalence, prefer the phasor-based examples first. They compare For solver equivalence, prefer the phasor-based examples first. They compare

View file

@ -1,134 +0,0 @@
"""
Local-mode CMT example for a continuously varying rib-waveguide taper.
This example keeps geometry construction outside `meanas.fdfd.eme`: it samples a
width taper at several cross-sections, solves and normalizes each local mode with
`waveguide_2d`, then asks `eme.get_taper_s(...)` for the bidirectional taper
scattering matrix.
"""
from __future__ import annotations
import numpy
from numpy import pi
from meanas.fdmath import vec
from meanas.fdfd import eme, waveguide_2d
WL = 1310.0
DX = 80.0
TAPER_LENGTH = 4e3
WIDTH_LEFT = 280.0
WIDTH_RIGHT = 520.0
THF = 160.0
THP = 80.0
EPS_SI = 3.51 ** 2
EPS_OX = 1.453 ** 2
MODE_NUMBERS = numpy.array([0])
N_SECTIONS = 7
def build_dxes(shape: tuple[int, int], dx: float = DX) -> list[list[numpy.ndarray]]:
nx, ny = shape
return [
[numpy.full(nx, dx), numpy.full(ny, dx)],
[numpy.full(nx, dx), numpy.full(ny, dx)],
]
def build_cross_section(
*,
width: float,
x: numpy.ndarray,
y: numpy.ndarray,
eps_si: float = EPS_SI,
eps_ox: float = EPS_OX,
thf: float = THF,
thp: float = THP,
) -> numpy.ndarray:
epsilon = numpy.full((3, x.size, y.size), eps_ox, dtype=float)
xx = x[:, None]
yy = y[None, :]
slab = (yy >= 0) & (yy <= thp)
rib = (abs(xx) <= width / 2) & (yy >= 0) & (yy <= thf)
epsilon[:, slab.repeat(x.size, axis=0)] = eps_si
epsilon[:, rib] = eps_si
return epsilon
def solve_cross_section_modes(
epsilon: numpy.ndarray,
*,
omega: float,
dxes_2d: list[list[numpy.ndarray]],
mode_numbers: numpy.ndarray = MODE_NUMBERS,
) -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray]:
epsilon_vec = vec(epsilon)
e_xys, wavenumbers = waveguide_2d.solve_modes(
epsilon=epsilon_vec,
omega=omega,
dxes=dxes_2d,
mode_numbers=mode_numbers,
)
eh_fields = [
waveguide_2d.normalized_fields_e(
e_xy,
wavenumber=wavenumber,
dxes=dxes_2d,
omega=omega,
epsilon=epsilon_vec,
)
for e_xy, wavenumber in zip(e_xys, wavenumbers, strict=True)
]
return eh_fields, wavenumbers
def solve_taper_sections() -> tuple[list[eme.ModeSection], list[float], float, list[list[numpy.ndarray]]]:
omega = 2 * pi / WL
x = numpy.arange(-480, 480 + DX, DX)
y = numpy.arange(-240, 400 + DX, DX)
dxes_2d = build_dxes((x.size, y.size))
z_samples = numpy.linspace(0, TAPER_LENGTH, N_SECTIONS)
widths = numpy.linspace(WIDTH_LEFT, WIDTH_RIGHT, N_SECTIONS)
sections = []
neffs = []
for z_coord, width in zip(z_samples, widths, strict=True):
epsilon = build_cross_section(width=float(width), x=x, y=y)
modes, wavenumbers = solve_cross_section_modes(epsilon, omega=omega, dxes_2d=dxes_2d)
sections.append(eme.ModeSection(float(z_coord), modes, wavenumbers))
neffs.append(float(numpy.real(wavenumbers[0] / omega)))
return sections, neffs, omega, dxes_2d
def print_summary(
taper_s: numpy.ndarray,
abrupt_s: numpy.ndarray,
neffs: list[float],
) -> None:
n_modes = len(MODE_NUMBERS)
print('sampled taper effective indices:', ', '.join(f'{value:.5f}' for value in neffs))
print(f'abrupt endpoint reflection |S_00|^2 = {abs(abrupt_s[0, 0]) ** 2:.6f}')
print(f'abrupt endpoint transmission |S_{n_modes},0|^2 = {abs(abrupt_s[n_modes, 0]) ** 2:.6f}')
print(f'taper CMT reflection |S_00|^2 = {abs(taper_s[0, 0]) ** 2:.6f}')
print(f'taper CMT transmission |S_{n_modes},0|^2 = {abs(taper_s[n_modes, 0]) ** 2:.6f}')
print(f'taper CMT total output power = {numpy.sum(abs(taper_s[:, 0]) ** 2):.6f}')
def main() -> None:
sections, neffs, _omega, dxes_2d = solve_taper_sections()
taper_s = eme.get_taper_s(sections, dxes=dxes_2d)
abrupt_s = eme.get_s(
sections[0].modes,
sections[0].wavenumbers,
sections[-1].modes,
sections[-1].wavenumbers,
dxes=dxes_2d,
)
print_summary(taper_s, abrupt_s, neffs)
if __name__ == '__main__':
main()

View file

@ -13,9 +13,6 @@ The returned matrices follow the usual port ordering:
directional `T/R` solves. directional `T/R` solves.
- `get_s(...)` returns the full block scattering matrix - `get_s(...)` returns the full block scattering matrix
`[[R12, T12], [T21, R21]]`. `[[R12, T12], [T21, R21]]`.
- `get_taper_abcd(...)` and `get_taper_s(...)` build the same transfer /
scattering objects for sampled continuously varying sections using local-mode
CMT.
This module is intentionally a thin library layer rather than an integrated This module is intentionally a thin library layer rather than an integrated
simulation suite. It provides the overlap algebra that downstream users can simulation suite. It provides the overlap algebra that downstream users can
@ -23,51 +20,19 @@ compose into larger workflows.
""" """
from collections.abc import Sequence from collections.abc import Sequence
import dataclasses
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray
from scipy import linalg
from scipy import sparse from scipy import sparse
from ..fdmath import dx_lists2_t, vcfdfield2 from ..fdmath import dx_lists2_t, vcfdfield2
from .waveguide_2d import inner_product from .waveguide_2d import inner_product
type wavenumber_seq = Sequence[complex] | NDArray[numpy.complexfloating] | NDArray[numpy.floating] type wavenumber_seq = Sequence[complex] | NDArray[numpy.complexfloating] | NDArray[numpy.floating]
type mode_seq = Sequence[Sequence[vcfdfield2]]
@dataclasses.dataclass(frozen=True)
class ModeSection:
"""
Local modal basis at one longitudinal sample of a continuously varying guide.
Args:
z: Longitudinal coordinate of this section.
modes: Forward modes as `(E, H)` field pairs.
wavenumbers: Forward propagation constants for `modes`.
backward_modes: Optional explicit backward modes. If omitted, backward
modes are synthesized as `(E, -H)`.
backward_wavenumbers: Optional propagation constants for
`backward_modes`. If omitted, they are synthesized as `-wavenumbers`.
dual_modes: Optional forward dual / adjoint projection modes. If
omitted, `modes` are used as their own projection basis.
dual_backward_modes: Optional backward dual / adjoint projection modes.
If omitted, they are synthesized from `dual_modes` when available,
otherwise from `backward_modes`.
"""
z: float
modes: mode_seq
wavenumbers: wavenumber_seq
backward_modes: mode_seq | None = None
backward_wavenumbers: wavenumber_seq | None = None
dual_modes: mode_seq | None = None
dual_backward_modes: mode_seq | None = None
def _validate_port_modes( def _validate_port_modes(
name: str, name: str,
ehs: mode_seq, ehs: Sequence[Sequence[vcfdfield2]],
wavenumbers: wavenumber_seq, wavenumbers: wavenumber_seq,
) -> tuple[tuple[int, ...], tuple[int, ...]]: ) -> tuple[tuple[int, ...], tuple[int, ...]]:
if len(ehs) != len(wavenumbers): if len(ehs) != len(wavenumbers):
@ -96,274 +61,12 @@ def _validate_port_modes(
return e_shape, h_shape return e_shape, h_shape
def _validate_dual_modes(
name: str,
dual_ehs: mode_seq | None,
reference_shape: tuple[int, ...],
wavenumbers: wavenumber_seq,
) -> mode_seq | None:
if dual_ehs is None:
return None
dual_e_shape, dual_h_shape = _validate_port_modes(name, dual_ehs, wavenumbers)
if dual_e_shape != reference_shape or dual_h_shape != reference_shape:
raise ValueError(f'{name} modal fields must share the same E/H shapes as the corresponding modes')
return dual_ehs
def _as_wavenumber_array(
name: str,
wavenumbers: wavenumber_seq,
) -> NDArray[numpy.complex128]:
array = numpy.asarray(wavenumbers, dtype=complex)
if array.ndim != 1:
raise ValueError(f'{name} must be a one-dimensional sequence')
if not numpy.isfinite(array).all():
raise ValueError(f'{name} must contain only finite values')
return array
def _as_mode_arrays(
ehs: mode_seq,
) -> list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]:
return [
(numpy.asarray(e_field, dtype=complex), numpy.asarray(h_field, dtype=complex))
for e_field, h_field in ehs
]
def _lorentz_overlap(
mode_a: tuple[vcfdfield2, vcfdfield2],
mode_b: tuple[vcfdfield2, vcfdfield2],
dxes: dx_lists2_t,
) -> complex:
e_a, h_a = mode_a
e_b, h_b = mode_b
return 0.5 * (
inner_product(e_a, h_b, dxes=dxes, conj_h=False)
+ inner_product(e_b, h_a, dxes=dxes, conj_h=False)
)
def _lorentz_derivative_overlap(
mode_a: tuple[vcfdfield2, vcfdfield2],
derivative_b: tuple[vcfdfield2, vcfdfield2],
dxes: dx_lists2_t,
) -> complex:
e_a, h_a = mode_a
de_b, dh_b = derivative_b
return 0.5 * (
inner_product(e_a, dh_b, dxes=dxes, conj_h=False)
+ inner_product(de_b, h_a, dxes=dxes, conj_h=False)
)
def _phase_align_modes(
previous: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
current: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
dxes: dx_lists2_t,
previous_dual: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]] | None = None,
) -> list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]:
aligned = []
test_modes = previous if previous_dual is None else previous_dual
for index, (previous_mode, current_mode, test_mode) in enumerate(zip(previous, current, test_modes, strict=True)):
overlap = _lorentz_overlap(test_mode, current_mode, dxes)
self_overlap = _lorentz_overlap(test_mode, previous_mode, dxes)
if overlap == 0:
raise ValueError(f'cannot phase-track mode {index}: adjacent section overlap is zero')
if self_overlap == 0:
raise ValueError(f'cannot phase-track mode {index}: mode dual-overlap is zero')
phase = (overlap / abs(overlap)) / (self_overlap / abs(self_overlap))
e_field, h_field = current_mode
aligned.append((e_field / phase, h_field / phase))
return aligned
def _section_branches(
section: ModeSection,
index: int,
expected_count: int | None,
expected_shape: tuple[int, ...] | None,
) -> tuple[
float,
list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
NDArray[numpy.complex128],
list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
NDArray[numpy.complex128],
list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
tuple[int, ...],
]:
z_coord = float(section.z)
if not numpy.isfinite(z_coord):
raise ValueError(f'sections[{index}].z must be finite')
shape, _h_shape = _validate_port_modes(f'sections[{index}].modes', section.modes, section.wavenumbers)
wavenumbers = _as_wavenumber_array(f'sections[{index}].wavenumbers', section.wavenumbers)
if expected_count is not None and len(wavenumbers) != expected_count:
raise ValueError('all taper sections must contain the same number of modes')
if expected_shape is not None and shape != expected_shape:
raise ValueError('all taper section modal fields must share the same E/H shapes')
if (section.backward_modes is None) != (section.backward_wavenumbers is None):
raise ValueError('backward_modes and backward_wavenumbers must be supplied together')
forward_modes = _as_mode_arrays(section.modes)
if section.backward_modes is None:
backward_modes = [(e_field.copy(), -h_field.copy()) for e_field, h_field in forward_modes]
backward_wavenumbers = -wavenumbers
else:
backward_shape, _backward_h_shape = _validate_port_modes(
f'sections[{index}].backward_modes',
section.backward_modes,
section.backward_wavenumbers,
)
if backward_shape != shape:
raise ValueError('forward and backward modal fields must share the same E/H shapes')
backward_wavenumbers = _as_wavenumber_array(
f'sections[{index}].backward_wavenumbers',
section.backward_wavenumbers,
)
backward_modes = _as_mode_arrays(section.backward_modes)
if len(backward_wavenumbers) != len(wavenumbers):
raise ValueError('forward and backward mode counts must match')
if section.dual_modes is None:
dual_modes = forward_modes
else:
dual_shape, _dual_h_shape = _validate_port_modes(
f'sections[{index}].dual_modes',
section.dual_modes,
section.wavenumbers,
)
if dual_shape != shape:
raise ValueError('modal fields and dual modal fields must share the same E/H shapes')
dual_modes = _as_mode_arrays(section.dual_modes)
if section.dual_backward_modes is None:
if section.dual_modes is None and section.backward_modes is not None:
dual_backward_modes = backward_modes
else:
dual_backward_modes = [(e_field.copy(), -h_field.copy()) for e_field, h_field in dual_modes]
else:
dual_backward_shape, _dual_backward_h_shape = _validate_port_modes(
f'sections[{index}].dual_backward_modes',
section.dual_backward_modes,
section.backward_wavenumbers if section.backward_wavenumbers is not None else backward_wavenumbers,
)
if dual_backward_shape != shape:
raise ValueError('backward modal fields and dual backward modal fields must share the same E/H shapes')
dual_backward_modes = _as_mode_arrays(section.dual_backward_modes)
if len(dual_modes) != len(forward_modes) or len(dual_backward_modes) != len(backward_modes):
raise ValueError('dual mode counts must match the corresponding modal basis counts')
return z_coord, forward_modes, wavenumbers, backward_modes, backward_wavenumbers, dual_modes, dual_backward_modes, shape
def _validate_taper_sections(
sections: Sequence[ModeSection],
dxes: dx_lists2_t,
) -> tuple[
NDArray[numpy.float64],
list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]],
list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]],
list[NDArray[numpy.complex128]],
int,
]:
if len(sections) < 2:
raise ValueError('at least two taper sections are required')
z_coords: list[float] = []
branch_modes: list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]] = []
branch_dual_modes: list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]] = []
branch_wavenumbers: list[NDArray[numpy.complex128]] = []
explicit_duals: list[bool] = []
expected_count: int | None = None
expected_shape: tuple[int, ...] | None = None
for index, section in enumerate(sections):
z_coord, forward_modes, forward_wavenumbers, backward_modes, backward_wavenumbers, dual_modes, dual_backward_modes, shape = _section_branches(
section,
index,
expected_count,
expected_shape,
)
if expected_count is None:
expected_count = len(forward_wavenumbers)
expected_shape = shape
z_coords.append(z_coord)
branch_modes.append([*forward_modes, *backward_modes])
branch_dual_modes.append([*dual_modes, *dual_backward_modes])
branch_wavenumbers.append(numpy.concatenate((forward_wavenumbers, backward_wavenumbers)))
explicit_duals.append(section.dual_modes is not None or section.dual_backward_modes is not None)
z_array = numpy.asarray(z_coords, dtype=float)
if not (numpy.diff(z_array) > 0).all():
raise ValueError('taper section z coordinates must be strictly increasing')
for index in range(1, len(branch_modes)):
branch_modes[index] = _phase_align_modes(branch_modes[index - 1], branch_modes[index], dxes, branch_dual_modes[index - 1])
if not explicit_duals[index]:
branch_dual_modes[index] = branch_modes[index]
assert expected_count is not None
return z_array, branch_modes, branch_dual_modes, branch_wavenumbers, expected_count
def _taper_interval_generator(
left_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
left_dual_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
right_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
left_wavenumbers: NDArray[numpy.complex128],
right_wavenumbers: NDArray[numpy.complex128],
dz: float,
dxes: dx_lists2_t,
) -> NDArray[numpy.complex128]:
mode_count = len(left_modes)
gram = numpy.zeros((mode_count, mode_count), dtype=complex)
derivative_overlap = numpy.zeros((mode_count, mode_count), dtype=complex)
for row, left_row_mode in enumerate(left_dual_modes):
for col, left_col_mode in enumerate(left_modes):
gram[row, col] = _lorentz_overlap(left_row_mode, left_col_mode, dxes)
for col, (left_col_mode, right_col_mode) in enumerate(zip(left_modes, right_modes, strict=True)):
derivative = (
(right_col_mode[0] - left_col_mode[0]) / dz,
(right_col_mode[1] - left_col_mode[1]) / dz,
)
derivative_overlap[row, col] = _lorentz_derivative_overlap(left_row_mode, derivative, dxes)
coupling = numpy.linalg.pinv(gram) @ derivative_overlap
propagation = numpy.diag(-1j * 0.5 * (left_wavenumbers + right_wavenumbers))
return propagation - coupling
def _abcd_to_s(
abcd: NDArray[numpy.complex128],
n_modes: int,
) -> NDArray[numpy.complex128]:
A = abcd[:n_modes, :n_modes]
B = abcd[:n_modes, n_modes:]
C = abcd[n_modes:, :n_modes]
D = abcd[n_modes:, n_modes:]
D_inv = numpy.linalg.pinv(D)
r12 = -D_inv @ C
t21 = D_inv
t12 = A - B @ D_inv @ C
r21 = B @ D_inv
return numpy.block([[r12, t12],
[t21, r21]])
def get_tr( def get_tr(
ehLs: mode_seq, ehLs: Sequence[Sequence[vcfdfield2]],
wavenumbers_L: wavenumber_seq, wavenumbers_L: wavenumber_seq,
ehRs: mode_seq, ehRs: Sequence[Sequence[vcfdfield2]],
wavenumbers_R: wavenumber_seq, wavenumbers_R: wavenumber_seq,
dxes: dx_lists2_t, dxes: dx_lists2_t,
dual_ehLs: mode_seq | None = None,
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]: ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
""" """
Compute left-incident transmission and reflection matrices. Compute left-incident transmission and reflection matrices.
@ -374,8 +77,6 @@ def get_tr(
ehRs: Right-port modes as `(E, H)` field pairs. ehRs: Right-port modes as `(E, H)` field pairs.
wavenumbers_R: Propagation constants for `ehRs`. wavenumbers_R: Propagation constants for `ehRs`.
dxes: Two-dimensional Yee-cell edge lengths for the shared port plane. dxes: Two-dimensional Yee-cell edge lengths for the shared port plane.
dual_ehLs: Optional left-port dual / adjoint projection modes. If
omitted, `ehLs` are used as their own projection basis.
Returns: Returns:
`(T12, R12)` where columns index left-incident modes and rows index `(T12, R12)` where columns index left-incident modes and rows index
@ -389,8 +90,6 @@ def get_tr(
right_e_shape, right_h_shape = _validate_port_modes('ehRs', ehRs, wavenumbers_R) right_e_shape, right_h_shape = _validate_port_modes('ehRs', ehRs, wavenumbers_R)
if left_e_shape != right_e_shape or left_h_shape != right_h_shape: if left_e_shape != right_e_shape or left_h_shape != right_h_shape:
raise ValueError('left and right modal fields must share the same E/H shapes') raise ValueError('left and right modal fields must share the same E/H shapes')
dual_projection_ehLs = _validate_dual_modes('dual_ehLs', dual_ehLs, left_e_shape, wavenumbers_L)
projection_ehLs = ehLs if dual_projection_ehLs is None else dual_projection_ehLs
nL = len(wavenumbers_L) nL = len(wavenumbers_L)
nR = len(wavenumbers_R) nR = len(wavenumbers_R)
@ -399,12 +98,11 @@ def get_tr(
B11 = numpy.zeros((nL,), dtype=complex) B11 = numpy.zeros((nL,), dtype=complex)
for ll in range(nL): for ll in range(nL):
eL, hL = ehLs[ll] eL, hL = ehLs[ll]
eP, hP = projection_ehLs[ll] B11[ll] = inner_product(eL, hL, dxes=dxes, conj_h=False)
B11[ll] = inner_product(eL, hP, dxes=dxes, conj_h=False)
for rr in range(nR): for rr in range(nR):
eR, hR = ehRs[rr] eR, hR = ehRs[rr]
A12[ll, rr] = inner_product(eP, hR, dxes=dxes, conj_h=False) # TODO optimize loop? A12[ll, rr] = inner_product(eL, hR, dxes=dxes, conj_h=False) # TODO optimize loop?
A21[ll, rr] = inner_product(eR, hP, dxes=dxes, conj_h=False) A21[ll, rr] = inner_product(eR, hL, dxes=dxes, conj_h=False)
# tt0 = 2 * numpy.linalg.pinv(A21 + numpy.conj(A12)) # tt0 = 2 * numpy.linalg.pinv(A21 + numpy.conj(A12))
tt0, _resid, _rank, _sing = numpy.linalg.lstsq(A21 + A12, numpy.diag(2 * B11), rcond=None) tt0, _resid, _rank, _sing = numpy.linalg.lstsq(A21 + A12, numpy.diag(2 * B11), rcond=None)
@ -421,12 +119,10 @@ def get_tr(
def get_abcd( def get_abcd(
ehLs: mode_seq, ehLs: Sequence[Sequence[vcfdfield2]],
wavenumbers_L: wavenumber_seq, wavenumbers_L: wavenumber_seq,
ehRs: mode_seq, ehRs: Sequence[Sequence[vcfdfield2]],
wavenumbers_R: wavenumber_seq, wavenumbers_R: wavenumber_seq,
dual_ehLs: mode_seq | None = None,
dual_ehRs: mode_seq | None = None,
**kwargs, **kwargs,
) -> sparse.sparray: ) -> sparse.sparray:
""" """
@ -439,8 +135,8 @@ def get_abcd(
convention. convention.
""" """
t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dual_ehLs=dual_ehLs, **kwargs) t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, **kwargs)
t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, dual_ehLs=dual_ehRs, **kwargs) t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, **kwargs)
t21i = numpy.linalg.pinv(t21) t21i = numpy.linalg.pinv(t21)
A = t12 - r21 @ t21i @ r12 A = t12 - r21 @ t21i @ r12
B = r21 @ t21i B = r21 @ t21i
@ -456,14 +152,12 @@ def get_abcd(
def get_s( def get_s(
ehLs: mode_seq, ehLs: Sequence[Sequence[vcfdfield2]],
wavenumbers_L: wavenumber_seq, wavenumbers_L: wavenumber_seq,
ehRs: mode_seq, ehRs: Sequence[Sequence[vcfdfield2]],
wavenumbers_R: wavenumber_seq, wavenumbers_R: wavenumber_seq,
force_nogain: bool = False, force_nogain: bool = False,
force_reciprocal: bool = False, force_reciprocal: bool = False,
dual_ehLs: mode_seq | None = None,
dual_ehRs: mode_seq | None = None,
**kwargs, **kwargs,
) -> NDArray[numpy.complex128]: ) -> NDArray[numpy.complex128]:
""" """
@ -478,11 +172,9 @@ def get_s(
scattering matrix to at most one. scattering matrix to at most one.
force_reciprocal: If `True`, symmetrize the assembled matrix as force_reciprocal: If `True`, symmetrize the assembled matrix as
`0.5 * (S + S.T)`. `0.5 * (S + S.T)`.
dual_ehLs: Optional left-port dual / adjoint projection modes.
dual_ehRs: Optional right-port dual / adjoint projection modes.
""" """
t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dual_ehLs=dual_ehLs, **kwargs) t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, **kwargs)
t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, dual_ehLs=dual_ehRs, **kwargs) t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, **kwargs)
ss = numpy.block([[r12, t12], ss = numpy.block([[r12, t12],
[t21, r21]]) [t21, r21]])
@ -496,93 +188,3 @@ def get_s(
ss = 0.5 * (ss + ss.T) ss = 0.5 * (ss + ss.T)
return ss return ss
def get_taper_abcd(
sections: Sequence[ModeSection],
dxes: dx_lists2_t,
*,
rtol: float = 1e-7,
atol: float = 1e-9,
max_step: float | None = None,
) -> sparse.sparray:
"""
Build a bidirectional transfer matrix for a continuously varying taper.
The taper is represented by local modal bases sampled at increasing `z`
coordinates. Adjacent modal phases are tracked with the same non-conjugated
Lorentz/Poynting overlap used by the abrupt-interface helpers, then each
interval is propagated with a finite-difference local-mode CMT generator.
If a `ModeSection` supplies dual / adjoint modes, those modes are used for
the CMT projection. This supports leaky or radiative mode sets whose natural
projection basis is biorthogonal rather than self-projected.
Args:
sections: Local modal samples ordered by increasing `z`.
dxes: Two-dimensional Yee-cell edge lengths for the shared port plane.
rtol: Relative tolerance reserved for future adaptive CMT integrators.
Must be positive.
atol: Absolute tolerance reserved for future adaptive CMT integrators.
Must be positive.
max_step: Optional maximum matrix-exponential step inside each sampled
interval. This does not change the piecewise-constant interval
generator, but can improve conditioning for long intervals.
Returns:
Sparse block transfer matrix ordered as `[forward, backward]`.
"""
if rtol <= 0:
raise ValueError('rtol must be positive')
if atol <= 0:
raise ValueError('atol must be positive')
if max_step is not None and max_step <= 0:
raise ValueError('max_step must be positive')
z_coords, branch_modes, branch_dual_modes, branch_wavenumbers, n_modes = _validate_taper_sections(sections, dxes)
branch_count = 2 * n_modes
transfer = numpy.eye(branch_count, dtype=complex)
for index, dz in enumerate(numpy.diff(z_coords)):
generator = _taper_interval_generator(
branch_modes[index],
branch_dual_modes[index],
branch_modes[index + 1],
branch_wavenumbers[index],
branch_wavenumbers[index + 1],
float(dz),
dxes,
)
step_count = 1 if max_step is None else max(1, int(numpy.ceil(dz / max_step)))
interval_transfer = linalg.expm(generator * (dz / step_count))
for _step in range(step_count):
transfer = interval_transfer @ transfer
return sparse.csr_array(transfer)
def get_taper_s(
sections: Sequence[ModeSection],
dxes: dx_lists2_t,
*,
force_nogain: bool = False,
force_reciprocal: bool = False,
**kwargs,
) -> NDArray[numpy.complex128]:
"""
Build the full block scattering matrix for a continuously varying taper.
The returned matrix uses the same ordering as `get_s(...)`:
`[[R12, T12], [T21, R21]]`.
"""
_z_coords, _branch_modes, _branch_dual_modes, _branch_wavenumbers, n_modes = _validate_taper_sections(sections, dxes)
abcd = get_taper_abcd(sections, dxes, **kwargs).toarray()
ss = _abcd_to_s(abcd, n_modes)
if force_nogain:
U, sing, Vh = numpy.linalg.svd(ss)
ss = U @ numpy.diag(numpy.minimum(sing, 1.0)) @ Vh
if force_reciprocal:
ss = 0.5 * (ss + ss.T)
return ss

View file

@ -43,9 +43,39 @@ T_b &= \operatorname{diag}(r_b / r_{\min}).
$$ $$
With the same forward/backward derivative notation used in `waveguide_2d`, the With the same forward/backward derivative notation used in `waveguide_2d`, the
implementation treats the transverse electric eigenproblem as the canonical coordinate-transformed discrete curl equations used here are
cylindrical discretization. It reduces to `waveguide_2d.operator_e(...)` in the
large-radius limit `T_a, T_b \to I`. The eigenproblem implemented by $$
\begin{aligned}
-\imath \omega \mu_{rr} H_r &= \tilde{\partial}_y E_z + \imath \beta T_a^{-1} E_y, \\
-\imath \omega \mu_{yy} H_y &= -\imath \beta T_b^{-1} E_r
- T_b^{-1} \tilde{\partial}_r (T_a E_z), \\
-\imath \omega \mu_{zz} H_z &= \tilde{\partial}_r E_y - \tilde{\partial}_y E_r, \\
\imath \beta H_y &= -\imath \omega T_b \epsilon_{rr} E_r - T_b \hat{\partial}_y H_z, \\
\imath \beta H_r &= \imath \omega T_a \epsilon_{yy} E_y
- T_b T_a^{-1} \hat{\partial}_r (T_b H_z), \\
\imath \omega E_z &= T_a \epsilon_{zz}^{-1}
\left(\hat{\partial}_r H_y - \hat{\partial}_y H_r\right).
\end{aligned}
$$
The first three equations are the cylindrical analogue of the straight-guide
relations for `H_r`, `H_y`, and `H_z`. The next two are the metric-weighted
versions of the straight-guide identities for `\imath \beta H_y` and
`\imath \beta H_r`, and the last equation plays the same role as the
longitudinal `E_z` reconstruction in `waveguide_2d`.
Following the same elimination steps as in `waveguide_2d`, apply
`\imath \beta \tilde{\partial}_r` and `\imath \beta \tilde{\partial}_y` to the
equation for `E_z`, substitute for `\imath \beta H_r` and `\imath \beta H_y`,
and then eliminate `H_z` with
$$
H_z = \frac{1}{-\imath \omega \mu_{zz}}
\left(\tilde{\partial}_r E_y - \tilde{\partial}_y E_r\right).
$$
This yields the transverse electric eigenproblem implemented by
`cylindrical_operator(...)`: `cylindrical_operator(...)`:
$$ $$
@ -81,33 +111,6 @@ T_a \epsilon_{zz}^{-1}
\begin{bmatrix} E_r \\ E_y \end{bmatrix}. \begin{bmatrix} E_r \\ E_y \end{bmatrix}.
$$ $$
The full fields reconstructed by `exy2e(...)` and `e2h(...)` use the matching
large-radius-compatible identities
$$
E_z =
\frac{1}{\imath \beta} T_a \epsilon_{zz}^{-1}
\begin{bmatrix}
\hat{\partial}_r T_b \epsilon_{rr} &
\hat{\partial}_y T_a \epsilon_{yy}
\end{bmatrix}
\begin{bmatrix} E_r \\ E_y \end{bmatrix},
$$
and
$$
\begin{bmatrix} H_r \\ H_y \\ H_z \end{bmatrix}
=
\frac{1}{-\imath \omega}\mu^{-1}
\begin{bmatrix}
0 & \imath\beta T_a^{-1} & \tilde{\partial}_y \\
-\imath\beta T_b^{-1} & 0 & -T_b^{-1}\tilde{\partial}_r T_a \\
-\tilde{\partial}_y & \tilde{\partial}_r & 0
\end{bmatrix}
\begin{bmatrix} E_r \\ E_y \\ E_z \end{bmatrix}.
$$
Since `\beta = m / r_{\min}`, the solver implemented in this file returns the Since `\beta = m / r_{\min}`, the solver implemented in this file returns the
angular wavenumber `m`, while the operator itself is most naturally written in angular wavenumber `m`, while the operator itself is most naturally written in
terms of the linear quantity `\beta`. The helpers below reconstruct the full terms of the linear quantity `\beta`. The helpers below reconstruct the full
@ -140,7 +143,6 @@ def cylindrical_operator(
dxes: dx_lists2_t, dxes: dx_lists2_t,
epsilon: vfdslice, epsilon: vfdslice,
rmin: float, rmin: float,
mu: vfdslice | None = None,
) -> sparse.sparray: ) -> sparse.sparray:
r""" r"""
Cylindrical coordinate waveguide operator of the form Cylindrical coordinate waveguide operator of the form
@ -174,13 +176,10 @@ def cylindrical_operator(
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D) dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
epsilon: Vectorized dielectric constant grid epsilon: Vectorized dielectric constant grid
rmin: Radius at the left edge of the simulation domain (at minimum 'x') rmin: Radius at the left edge of the simulation domain (at minimum 'x')
mu: Vectorized magnetic permeability grid (default 1 everywhere)
Returns: Returns:
Sparse matrix representation of the operator Sparse matrix representation of the operator
""" """
if mu is None:
mu = numpy.ones_like(epsilon)
Dfx, Dfy = deriv_forward(dxes[0]) Dfx, Dfy = deriv_forward(dxes[0])
Dbx, Dby = deriv_back(dxes[1]) Dbx, Dby = deriv_back(dxes[1])
@ -192,17 +191,12 @@ def cylindrical_operator(
eps_y = sparse.diags_array(eps_parts[1]) eps_y = sparse.diags_array(eps_parts[1])
eps_z_inv = sparse.diags_array(1 / eps_parts[2]) eps_z_inv = sparse.diags_array(1 / eps_parts[2])
mu_parts = numpy.split(mu, 3)
mu_y = sparse.diags_array(mu_parts[1])
mu_x = sparse.diags_array(mu_parts[0])
mu_z_inv = sparse.diags_array(1 / mu_parts[2])
omega2 = omega * omega omega2 = omega * omega
diag = sparse.block_diag diag = sparse.block_diag
sq0 = omega2 * diag((Tb @ Tb @ mu_y @ eps_x, sq0 = omega2 * diag((Tb @ Tb @ eps_x,
Ta @ Ta @ mu_x @ eps_y)) Ta @ Ta @ eps_y))
lin0 = sparse.vstack((-Tb @ mu_y @ Dby, Ta @ mu_x @ Dbx)) @ Tb @ mu_z_inv @ sparse.hstack((-Dfy, Dfx)) lin0 = sparse.vstack((-Tb @ Dby, Ta @ Dbx)) @ Tb @ sparse.hstack((-Dfy, Dfx))
lin1 = sparse.vstack((Dfx, Dfy)) @ Ta @ eps_z_inv @ sparse.hstack((Dbx @ Tb @ eps_x, lin1 = sparse.vstack((Dfx, Dfy)) @ Ta @ eps_z_inv @ sparse.hstack((Dbx @ Tb @ eps_x,
Dby @ Ta @ eps_y)) Dby @ Ta @ eps_y))
op = sq0 + lin0 + lin1 op = sq0 + lin0 + lin1
@ -215,7 +209,6 @@ def solve_modes(
dxes: dx_lists2_t, dxes: dx_lists2_t,
epsilon: vfdslice, epsilon: vfdslice,
rmin: float, rmin: float,
mu: vfdslice | None = None,
mode_margin: int = 2, mode_margin: int = 2,
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]: ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
""" """
@ -230,7 +223,6 @@ def solve_modes(
epsilon: Dielectric constant epsilon: Dielectric constant
rmin: Radius of curvature for the simulation. This should be the minimum value of rmin: Radius of curvature for the simulation. This should be the minimum value of
r within the simulation domain. r within the simulation domain.
mu: Magnetic permeability (default 1 everywhere)
Returns: Returns:
e_xys: NDArray of vfdfield_t specifying fields. First dimension is mode number. e_xys: NDArray of vfdfield_t specifying fields. First dimension is mode number.
@ -241,9 +233,8 @@ def solve_modes(
# Solve for the largest-magnitude eigenvalue of the real operator # Solve for the largest-magnitude eigenvalue of the real operator
# #
dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes] dxes_real = [[numpy.real(dx) for dx in dxi] for dxi in dxes]
mu_real = None if mu is None else numpy.real(mu)
A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), rmin=rmin, mu=mu_real) A_r = cylindrical_operator(numpy.real(omega), dxes_real, numpy.real(epsilon), rmin=rmin)
eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin) eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin)
keep_inds = -(numpy.array(mode_numbers) + 1) keep_inds = -(numpy.array(mode_numbers) + 1)
e_xys = eigvecs[:, keep_inds].T e_xys = eigvecs[:, keep_inds].T
@ -253,7 +244,7 @@ def solve_modes(
# Now solve for the eigenvector of the full operator, using the real operator's # Now solve for the eigenvector of the full operator, using the real operator's
# eigenvector as an initial guess for Rayleigh quotient iteration. # eigenvector as an initial guess for Rayleigh quotient iteration.
# #
A = cylindrical_operator(omega, dxes, epsilon, rmin=rmin, mu=mu) A = cylindrical_operator(omega, dxes, epsilon, rmin=rmin)
for nn in range(len(mode_numbers)): for nn in range(len(mode_numbers)):
eigvals[nn], e_xys[nn, :] = rayleigh_quotient_iteration(A, e_xys[nn, :]) eigvals[nn], e_xys[nn, :] = rayleigh_quotient_iteration(A, e_xys[nn, :])
@ -321,20 +312,12 @@ def linear_wavenumbers(
shape2d = (len(dxes[0][0]), len(dxes[0][1])) shape2d = (len(dxes[0][0]), len(dxes[0][1]))
epsilon2d = unvec(epsilon, shape2d)[:2] epsilon2d = unvec(epsilon, shape2d)[:2]
ra = rmin + numpy.cumsum(dxes[0][0]) grid_radii = rmin + numpy.cumsum(dxes[0][0])
rb = rmin + dxes[0][0] / 2.0 + numpy.concatenate((
numpy.zeros(1, dtype=dxes[1][0].dtype),
numpy.cumsum(dxes[1][0][:-1]),
))
for ii in range(angular_wavenumbers.size): for ii in range(angular_wavenumbers.size):
efield = unvec(e_xys[ii], shape2d, 2) efield = unvec(e_xys[ii], shape2d, 2)
energy = numpy.real((efield * efield.conj()) * epsilon2d) energy = numpy.real((efield * efield.conj()) * epsilon2d)
er_energy_vs_r = energy[0].sum(axis=1) energy_vs_x = energy.sum(axis=(0, 2))
ey_energy_vs_r = energy[1].sum(axis=1) mode_radii[ii] = (grid_radii * energy_vs_x).sum() / energy_vs_x.sum()
energy_vs_r = er_energy_vs_r + ey_energy_vs_r
mode_radii[ii] = (
(rb * er_energy_vs_r).sum() + (ra * ey_energy_vs_r).sum()
) / energy_vs_r.sum()
logger.info(f'{mode_radii=}') logger.info(f'{mode_radii=}')
lin_wavenumbers = angular_wavenumbers / mode_radii lin_wavenumbers = angular_wavenumbers / mode_radii
@ -367,11 +350,12 @@ def exy2h(
Sparse matrix representing the operator. Sparse matrix representing the operator.
""" """
e2hop = e2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, mu=mu) e2hop = e2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, mu=mu)
return e2hop @ exy2e(angular_wavenumber=angular_wavenumber, dxes=dxes, rmin=rmin, epsilon=epsilon) return e2hop @ exy2e(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon)
def exy2e( def exy2e(
angular_wavenumber: complex, angular_wavenumber: complex,
omega: float,
dxes: dx_lists2_t, dxes: dx_lists2_t,
rmin: float, rmin: float,
epsilon: vfdslice, epsilon: vfdslice,
@ -387,6 +371,7 @@ def exy2e(
angular_wavenumber: Wavenumber assuming fields have theta-dependence of angular_wavenumber: Wavenumber assuming fields have theta-dependence of
`exp(-i * angular_wavenumber * theta)`. It should satisfy `exp(-i * angular_wavenumber * theta)`. It should satisfy
`operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy` `operator_e() @ e_xy == (angular_wavenumber / rmin) ** 2 * e_xy`
omega: The angular frequency of the system
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D) dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` (2D)
rmin: Radius at the left edge of the simulation domain (at minimum 'x') rmin: Radius at the left edge of the simulation domain (at minimum 'x')
epsilon: Vectorized dielectric constant grid epsilon: Vectorized dielectric constant grid
@ -394,22 +379,30 @@ def exy2e(
Returns: Returns:
Sparse matrix representing the operator. Sparse matrix representing the operator.
""" """
Dfx, Dfy = deriv_forward(dxes[0])
Dbx, Dby = deriv_back(dxes[1]) Dbx, Dby = deriv_back(dxes[1])
wavenumber = angular_wavenumber / rmin wavenumber = angular_wavenumber / rmin
Ta, Tb = dxes2T(dxes=dxes, rmin=rmin) Ta, Tb = dxes2T(dxes=dxes, rmin=rmin)
Tai = sparse.diags_array(1 / Ta.diagonal())
#Tbi = sparse.diags_array(1 / Tb.diagonal())
epsilon_parts = numpy.split(epsilon, 3) epsilon_parts = numpy.split(epsilon, 3)
epsilon_x, epsilon_y = (sparse.diags_array(epsi) for epsi in epsilon_parts[:2]) epsilon_x, epsilon_y = (sparse.diags_array(epsi) for epsi in epsilon_parts[:2])
epsilon_z_inv = sparse.diags_array(1 / epsilon_parts[2]) epsilon_z_inv = sparse.diags_array(1 / epsilon_parts[2])
n_pts = dxes[0][0].size * dxes[0][1].size n_pts = dxes[0][0].size * dxes[0][1].size
exy2ez = ( zeros = sparse.coo_array((n_pts, n_pts))
Ta @ epsilon_z_inv
@ sparse.hstack((Dbx @ Tb @ epsilon_x, mu_z = numpy.ones(n_pts)
Dby @ Ta @ epsilon_y)) mu_z_inv = sparse.diags_array(1 / mu_z)
/ (1j * wavenumber) exy2hz = 1 / (-1j * omega) * mu_z_inv @ sparse.hstack((Dfy, -Dfx))
) hxy2ez = 1 / (1j * omega) * epsilon_z_inv @ sparse.hstack((Dby, -Dbx))
exy2hy = Tb / (1j * wavenumber) @ (-1j * omega * sparse.hstack((epsilon_x, zeros)) - Dby @ exy2hz)
exy2hx = Tb / (1j * wavenumber) @ ( 1j * omega * sparse.hstack((zeros, epsilon_y)) - Tai @ Dbx @ Tb @ exy2hz)
exy2ez = hxy2ez @ sparse.vstack((exy2hx, exy2hy))
op = sparse.vstack((sparse.eye_array(2 * n_pts), op = sparse.vstack((sparse.eye_array(2 * n_pts),
exy2ez)) exy2ez))
@ -455,9 +448,9 @@ def e2h(
Tbi = sparse.diags_array(1 / Tb.diagonal()) Tbi = sparse.diags_array(1 / Tb.diagonal())
jB = 1j * angular_wavenumber / rmin jB = 1j * angular_wavenumber / rmin
op = sparse.block_array([[ None, jB * Tai, Dfy], op = sparse.block_array([[ None, -jB * Tai, -Dfy],
[-jB * Tbi, None, -Tbi @ Dfx @ Ta], [jB * Tbi, None, Tbi @ Dfx @ Ta],
[ -Dfy, Dfx, None]]) / (-1j * omega) [ Dfy, -Dfx, None]]) / (-1j * omega)
if mu is not None: if mu is not None:
op = sparse.diags_array(1 / mu) @ op op = sparse.diags_array(1 / mu) @ op
return op return op
@ -482,14 +475,7 @@ def dxes2T(
Sparse diagonal matrices `(T_a, T_b)`. Sparse diagonal matrices `(T_a, T_b)`.
""" """
ra = rmin + numpy.cumsum(dxes[0][0]) # Radius at Ey points ra = rmin + numpy.cumsum(dxes[0][0]) # Radius at Ey points
rb = ( rb = rmin + dxes[0][0] / 2.0 + numpy.cumsum(dxes[1][0]) # Radius at Ex points
rmin
+ dxes[0][0] / 2.0
+ numpy.concatenate((
numpy.zeros(1, dtype=dxes[1][0].dtype),
numpy.cumsum(dxes[1][0][:-1]),
))
) # Radius at Er points
ta = ra / rmin ta = ra / rmin
tb = rb / rmin tb = rb / rmin
@ -541,7 +527,7 @@ def normalized_fields_e(
fields, then the overall complex phase and sign are fixed so the result is fields, then the overall complex phase and sign are fixed so the result is
reproducible for symmetric modes. reproducible for symmetric modes.
""" """
e = exy2e(angular_wavenumber=angular_wavenumber, dxes=dxes, rmin=rmin, epsilon=epsilon) @ e_xy e = exy2e(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon) @ e_xy
h = exy2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon, mu=mu) @ e_xy h = exy2h(angular_wavenumber=angular_wavenumber, omega=omega, dxes=dxes, rmin=rmin, epsilon=epsilon, mu=mu) @ e_xy
e_norm, h_norm = _normalized_fields( e_norm, h_norm = _normalized_fields(
e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase, e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase,
@ -567,16 +553,19 @@ def _normalized_fields(
The normalization procedure is: The normalization procedure is:
1. Compute the time-averaged forward power with 1. Flip the magnetic field sign so the reconstructed `(e, h)` pair follows
the same forward-power convention as `waveguide_2d`.
2. Compute the time-averaged forward power with
`waveguide_2d.inner_product(..., conj_h=True)`. `waveguide_2d.inner_product(..., conj_h=True)`.
2. Scale by `1 / sqrt(S_z)` so the resulting mode has unit forward power. 3. Scale by `1 / sqrt(S_z)` so the resulting mode has unit forward power.
3. Remove the arbitrary complex phase and apply a quadrant-sum sign heuristic 4. Remove the arbitrary complex phase and apply a quadrant-sum sign heuristic
so symmetric modes choose a stable representative. so symmetric modes choose a stable representative.
`prop_phase` has the same meaning as in `waveguide_2d`: it compensates for `prop_phase` has the same meaning as in `waveguide_2d`: it compensates for
the half-cell longitudinal staggering between the E and H fields when the the half-cell longitudinal staggering between the E and H fields when the
propagation direction is itself discretized. propagation direction is itself discretized.
""" """
h *= -1
shape = [s.size for s in dxes[0]] shape = [s.size for s in dxes[0]]
# Find time-averaged Sz and normalize to it # Find time-averaged Sz and normalize to it

View file

@ -77,27 +77,6 @@ def test_get_tr_returns_finite_bounded_transfer_matrices() -> None:
assert (singular_values <= 1.0 + 1e-12).all() assert (singular_values <= 1.0 + 1e-12).all()
def test_get_tr_accepts_scaled_dual_projection_modes() -> None:
left_modes, right_modes = _mode_sets()
dual_left_modes = [
(mode[0] * (0.5 + 0.25j), mode[1] * (0.5 + 0.25j))
for mode in left_modes
]
plain_t, plain_r = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
dual_t, dual_r = eme.get_tr(
left_modes,
WAVENUMBERS_L,
right_modes,
WAVENUMBERS_R,
dxes=DXES,
dual_ehLs=dual_left_modes,
)
assert_close(dual_t, plain_t)
assert_close(dual_r, plain_r)
def test_get_abcd_matches_explicit_block_formula() -> None: def test_get_abcd_matches_explicit_block_formula() -> None:
left_modes, right_modes = _mode_sets() left_modes, right_modes = _mode_sets()
t12, r12 = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES) t12, r12 = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
@ -187,20 +166,6 @@ def test_get_tr_rejects_incompatible_field_shapes() -> None:
eme.get_tr(left_modes, [1.0], right_modes, [1.0], dxes=DXES) eme.get_tr(left_modes, [1.0], right_modes, [1.0], dxes=DXES)
def test_get_tr_rejects_dual_mode_length_mismatches() -> None:
left_modes, right_modes = _mode_sets()
with pytest.raises(ValueError, match='same length'):
eme.get_tr(
left_modes,
WAVENUMBERS_L,
right_modes,
WAVENUMBERS_R,
dxes=DXES,
dual_ehLs=left_modes[:1],
)
def _build_real_epsilon() -> numpy.ndarray: def _build_real_epsilon() -> numpy.ndarray:
epsilon = numpy.ones((3, 5, 5), dtype=float) epsilon = numpy.ones((3, 5, 5), dtype=float)
epsilon[:, 2, 1] = 2.0 epsilon[:, 2, 1] = 2.0
@ -262,159 +227,6 @@ def _build_uniform_mode(index: float) -> tuple[tuple[numpy.ndarray, numpy.ndarra
return (vec(e_field), vec(h_field)), complex(index * OMEGA) return (vec(e_field), vec(h_field)), complex(index * OMEGA)
def test_get_taper_abcd_constant_section_is_phase_only() -> None:
mode, beta = _build_uniform_mode(1.5)
length = 11.0
abcd = eme.get_taper_abcd(
[
eme.ModeSection(0.0, [mode], [beta]),
eme.ModeSection(length, [mode], [beta]),
],
dxes=REAL_DXES,
).toarray()
assert_close(abcd, _propagation_abcd(beta, length), atol=1e-12, rtol=1e-12)
def test_get_taper_s_constant_section_has_no_reflection() -> None:
mode, beta = _build_uniform_mode(1.5)
length = 11.0
phase = numpy.exp(-1j * beta * length)
ss = eme.get_taper_s(
[
eme.ModeSection(0.0, [mode], [beta]),
eme.ModeSection(length, [mode], [beta]),
],
dxes=REAL_DXES,
)
assert_close(ss, numpy.array([[0.0, phase], [phase, 0.0]], dtype=complex), atol=1e-12, rtol=1e-12)
def test_get_taper_abcd_is_invariant_to_adjacent_modal_phase() -> None:
mode, beta = _build_uniform_mode(1.5)
shifted_mode = (mode[0] * numpy.exp(0.73j), mode[1] * numpy.exp(0.73j))
length = 11.0
base_sections = [
eme.ModeSection(0.0, [mode], [beta]),
eme.ModeSection(length, [mode], [beta]),
]
shifted_sections = [
eme.ModeSection(0.0, [mode], [beta]),
eme.ModeSection(length, [shifted_mode], [beta]),
]
base = eme.get_taper_abcd(base_sections, dxes=REAL_DXES).toarray()
shifted = eme.get_taper_abcd(shifted_sections, dxes=REAL_DXES).toarray()
assert_close(shifted, base, atol=1e-12, rtol=1e-12)
def test_get_taper_abcd_is_invariant_to_modal_phase_across_multiple_sections() -> None:
mode, beta = _build_uniform_mode(1.5)
mid_length = 5.0
length = 11.0
base_sections = [
eme.ModeSection(0.0, [mode], [beta]),
eme.ModeSection(mid_length, [mode], [beta]),
eme.ModeSection(length, [mode], [beta]),
]
shifted_sections = [
eme.ModeSection(0.0, [mode], [beta]),
eme.ModeSection(mid_length, [(mode[0] * numpy.exp(0.41j), mode[1] * numpy.exp(0.41j))], [beta]),
eme.ModeSection(length, [(mode[0] * numpy.exp(-0.28j), mode[1] * numpy.exp(-0.28j))], [beta]),
]
base = eme.get_taper_abcd(base_sections, dxes=REAL_DXES).toarray()
shifted = eme.get_taper_abcd(shifted_sections, dxes=REAL_DXES).toarray()
assert_close(shifted, base, atol=1e-12, rtol=1e-12)
def test_get_taper_accepts_complex_leaky_wavenumber() -> None:
mode, beta = _build_uniform_mode(1.5)
leaky_beta = beta - 0.02j
length = 3.0
abcd = eme.get_taper_abcd(
[
eme.ModeSection(0.0, [mode], [leaky_beta]),
eme.ModeSection(length, [mode], [leaky_beta]),
],
dxes=REAL_DXES,
).toarray()
assert_close(abcd, _propagation_abcd(leaky_beta, length), atol=1e-12, rtol=1e-12)
def test_get_taper_uses_supplied_dual_modes_for_phase_tracking() -> None:
mode, beta = _build_uniform_mode(1.5)
primal_scale = numpy.exp(0.42j)
dual_scale = 0.31 * numpy.exp(-0.77j)
dual_mode = (mode[0] * dual_scale, mode[1] * dual_scale)
shifted_mode = (mode[0] * primal_scale, mode[1] * primal_scale)
shifted_dual_mode = (dual_mode[0] * 2.3j, dual_mode[1] * 2.3j)
length = 11.0
base = eme.get_taper_abcd(
[
eme.ModeSection(0.0, [mode], [beta], dual_modes=[dual_mode]),
eme.ModeSection(length, [mode], [beta], dual_modes=[dual_mode]),
],
dxes=REAL_DXES,
).toarray()
shifted = eme.get_taper_abcd(
[
eme.ModeSection(0.0, [mode], [beta], dual_modes=[dual_mode]),
eme.ModeSection(length, [shifted_mode], [beta], dual_modes=[shifted_dual_mode]),
],
dxes=REAL_DXES,
).toarray()
assert_close(shifted, base, atol=1e-12, rtol=1e-12)
def test_get_taper_rejects_nonmonotonic_sections() -> None:
mode, beta = _build_uniform_mode(1.5)
with pytest.raises(ValueError, match='strictly increasing'):
eme.get_taper_abcd(
[
eme.ModeSection(0.0, [mode], [beta]),
eme.ModeSection(0.0, [mode], [beta]),
],
dxes=REAL_DXES,
)
def test_get_taper_rejects_mode_count_changes() -> None:
mode, beta = _build_uniform_mode(1.5)
with pytest.raises(ValueError, match='same number of modes'):
eme.get_taper_abcd(
[
eme.ModeSection(0.0, [mode], [beta]),
eme.ModeSection(1.0, [mode, mode], [beta, beta]),
],
dxes=REAL_DXES,
)
def test_get_taper_rejects_dual_mode_count_changes() -> None:
mode, beta = _build_uniform_mode(1.5)
with pytest.raises(ValueError, match='same length'):
eme.get_taper_abcd(
[
eme.ModeSection(0.0, [mode], [beta], dual_modes=[mode]),
eme.ModeSection(1.0, [mode], [beta], dual_modes=[mode, mode]),
],
dxes=REAL_DXES,
)
def _interface_s(n_left: float, n_right: float) -> numpy.ndarray: def _interface_s(n_left: float, n_right: float) -> numpy.ndarray:
left_mode, left_beta = _build_uniform_mode(n_left) left_mode, left_beta = _build_uniform_mode(n_left)
right_mode, right_beta = _build_uniform_mode(n_right) right_mode, right_beta = _build_uniform_mode(n_right)
@ -527,34 +339,6 @@ def test_get_s_matches_analytic_fresnel_interface_for_uniform_one_mode_ports() -
assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10 assert numpy.linalg.svd(ss, compute_uv=False).max() <= 1.0 + 1e-10
def test_get_s_with_dual_modes_matches_analytic_fresnel_interface() -> None:
left_mode, left_beta = _build_uniform_mode(1.0)
right_mode, right_beta = _build_uniform_mode(2.0)
left_dual = (left_mode[0] * (0.25 + 0.5j), left_mode[1] * (0.25 + 0.5j))
right_dual = (right_mode[0] * (-0.75 + 0.125j), right_mode[1] * (-0.75 + 0.125j))
ss = eme.get_s(
[left_mode],
[left_beta],
[right_mode],
[right_beta],
dxes=REAL_DXES,
dual_ehLs=[left_dual],
dual_ehRs=[right_dual],
)
expected = _expected_interface_s(1.0, 2.0)
assert_close(ss, expected, atol=1e-6, rtol=1e-6)
def test_get_s_accepts_complex_leaky_wavenumbers_for_abrupt_interface() -> None:
mode, beta = _build_uniform_mode(1.5)
ss = eme.get_s([mode], [beta - 0.02j], [mode], [beta - 0.03j], dxes=REAL_DXES)
assert_close(ss, numpy.array([[0.0, 1.0], [1.0, 0.0]], dtype=complex), atol=1e-12, rtol=1e-12)
def test_quarter_wave_matching_layer_is_nearly_reflectionless_at_design_frequency() -> None: def test_quarter_wave_matching_layer_is_nearly_reflectionless_at_design_frequency() -> None:
""" """
A single quarter-wave matching layer with A single quarter-wave matching layer with

View file

@ -45,11 +45,3 @@ def test_eme_bend_example_smoke_runs(tmp_path: Path) -> None:
assert result.returncode == 0, result.stdout + result.stderr assert result.returncode == 0, result.stdout + result.stderr
assert 'straight effective indices:' in result.stdout assert 'straight effective indices:' in result.stdout
assert 'cascaded bend through power' in result.stdout assert 'cascaded bend through power' in result.stdout
def test_eme_taper_cmt_example_smoke_runs(tmp_path: Path) -> None:
result = _run_example('eme_taper_cmt.py', tmp_path)
assert result.returncode == 0, result.stdout + result.stderr
assert 'sampled taper effective indices:' in result.stdout
assert 'taper CMT transmission' in result.stdout

View file

@ -35,7 +35,6 @@ def build_waveguide_3d_mode(
def build_waveguide_cyl_fixture( def build_waveguide_cyl_fixture(
*, *,
nonuniform: bool = False, nonuniform: bool = False,
asymmetric: bool = False,
) -> tuple[list[list[numpy.ndarray]], numpy.ndarray, float]: ) -> tuple[list[list[numpy.ndarray]], numpy.ndarray, float]:
if nonuniform: if nonuniform:
dxes = [ dxes = [
@ -44,17 +43,10 @@ def build_waveguide_cyl_fixture(
] ]
else: else:
dxes = [[numpy.ones(5), numpy.ones(5)] for _ in range(2)] dxes = [[numpy.ones(5), numpy.ones(5)] for _ in range(2)]
epsilon_3d = numpy.ones((3, 5, 5), dtype=float) epsilon = vec(numpy.ones((3, 5, 5), dtype=float))
if asymmetric:
epsilon_3d[:, 2, 1] = 2.0
epsilon = vec(epsilon_3d)
return dxes, epsilon, 10.0 return dxes, epsilon, 10.0
def build_waveguide_cyl_mu_profile() -> numpy.ndarray:
return numpy.linspace(1.5, 2.2, 3 * 5 * 5)
def test_waveguide_3d_solve_mode_and_expand_e_are_phase_consistent() -> None: def test_waveguide_3d_solve_mode_and_expand_e_are_phase_consistent() -> None:
epsilon, dxes, slices, result = build_waveguide_3d_mode(slice_start=0, polarity=1) epsilon, dxes, slices, result = build_waveguide_3d_mode(slice_start=0, polarity=1)
axis = 0 axis = 0
@ -181,10 +173,8 @@ def test_waveguide_3d_compute_overlap_e_rejects_zero_support_window() -> None:
) )
@pytest.mark.parametrize('use_mu', [False, True]) def test_waveguide_cyl_solved_modes_are_ordered_and_low_residual() -> None:
def test_waveguide_cyl_solved_modes_are_ordered_and_low_residual(use_mu: bool) -> None: dxes, epsilon, rmin = build_waveguide_cyl_fixture()
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( e_xys, angular_wavenumbers = waveguide_cyl.solve_modes(
[0, 1], [0, 1],
@ -192,9 +182,8 @@ def test_waveguide_cyl_solved_modes_are_ordered_and_low_residual(use_mu: bool) -
dxes=dxes, dxes=dxes,
epsilon=epsilon, epsilon=epsilon,
rmin=rmin, rmin=rmin,
mu=mu,
) )
operator = waveguide_cyl.cylindrical_operator(OMEGA, dxes, epsilon, rmin=rmin, mu=mu) operator = waveguide_cyl.cylindrical_operator(OMEGA, dxes, epsilon, rmin=rmin)
assert numpy.all(numpy.diff(numpy.real(angular_wavenumbers)) <= 0) assert numpy.all(numpy.diff(numpy.real(angular_wavenumbers)) <= 0)
@ -224,6 +213,7 @@ def test_waveguide_cyl_linear_wavenumbers_are_finite_and_ordered() -> None:
assert numpy.isfinite(linear_wavenumbers).all() assert numpy.isfinite(linear_wavenumbers).all()
assert numpy.all(numpy.real(linear_wavenumbers) > 0) assert numpy.all(numpy.real(linear_wavenumbers) > 0)
assert numpy.all(numpy.diff(numpy.real(linear_wavenumbers)) <= 0)
def test_waveguide_cyl_dxes2t_matches_expected_radius_scaling() -> None: def test_waveguide_cyl_dxes2t_matches_expected_radius_scaling() -> None:
@ -231,127 +221,26 @@ def test_waveguide_cyl_dxes2t_matches_expected_radius_scaling() -> None:
Ta, Tb = waveguide_cyl.dxes2T(dxes, rmin) Ta, Tb = waveguide_cyl.dxes2T(dxes, rmin)
ta = (rmin + numpy.cumsum(dxes[0][0])) / rmin ta = (rmin + numpy.cumsum(dxes[0][0])) / rmin
tb = ( tb = (rmin + dxes[0][0] / 2 + numpy.cumsum(dxes[1][0])) / rmin
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(Ta.diagonal(), numpy.repeat(ta, dxes[0][1].size))
numpy.testing.assert_allclose(Tb.diagonal(), numpy.repeat(tb, dxes[1][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)
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] / rb.mean())
numpy.testing.assert_allclose(ey_linear[0], angular_wavenumber[0] / ra.mean())
def test_waveguide_cyl_exy2e_and_exy2h_return_finite_full_fields() -> None: def test_waveguide_cyl_exy2e_and_exy2h_return_finite_full_fields() -> None:
dxes, epsilon, rmin = build_waveguide_cyl_fixture() dxes, epsilon, rmin = build_waveguide_cyl_fixture()
mu = build_waveguide_cyl_mu_profile() mu = vec(2 * numpy.ones((3, 5, 5), dtype=float))
e_xy, angular_wavenumber = waveguide_cyl.solve_mode( e_xy, angular_wavenumber = waveguide_cyl.solve_mode(
0, 0,
omega=OMEGA, omega=OMEGA,
dxes=dxes, dxes=dxes,
epsilon=epsilon, epsilon=epsilon,
rmin=rmin, rmin=rmin,
mu=mu,
) )
e_field = waveguide_cyl.exy2e( e_field = waveguide_cyl.exy2e(
angular_wavenumber=angular_wavenumber, angular_wavenumber=angular_wavenumber,
omega=OMEGA,
dxes=dxes, dxes=dxes,
rmin=rmin, rmin=rmin,
epsilon=epsilon, epsilon=epsilon,
@ -376,14 +265,13 @@ def test_waveguide_cyl_exy2e_and_exy2h_return_finite_full_fields() -> None:
@pytest.mark.parametrize('use_mu', [False, True]) @pytest.mark.parametrize('use_mu', [False, True])
def test_waveguide_cyl_normalized_fields_are_unit_norm_and_silent(use_mu: bool) -> None: def test_waveguide_cyl_normalized_fields_are_unit_norm_and_silent(use_mu: bool) -> None:
dxes, epsilon, rmin = build_waveguide_cyl_fixture() dxes, epsilon, rmin = build_waveguide_cyl_fixture()
mu = build_waveguide_cyl_mu_profile() if use_mu else None mu = vec(2 * numpy.ones((3, 5, 5), dtype=float)) if use_mu else None
e_xy, angular_wavenumber = waveguide_cyl.solve_mode( e_xy, angular_wavenumber = waveguide_cyl.solve_mode(
0, 0,
omega=OMEGA, omega=OMEGA,
dxes=dxes, dxes=dxes,
epsilon=epsilon, epsilon=epsilon,
rmin=rmin, rmin=rmin,
mu=mu,
) )
output = io.StringIO() output = io.StringIO()