[fdfd.eme] enable using leaky modes

This commit is contained in:
Forgejo Actions 2026-05-04 19:03:30 -07:00
commit 444ae49a74
2 changed files with 244 additions and 24 deletions

View file

@ -49,6 +49,11 @@ class ModeSection:
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
@ -56,6 +61,8 @@ class ModeSection:
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(
@ -89,6 +96,21 @@ def _validate_port_modes(
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,
@ -140,15 +162,17 @@ 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 = []
for index, (previous_mode, current_mode) in enumerate(zip(previous, current, strict=True)):
overlap = _lorentz_overlap(previous_mode, current_mode, dxes)
self_overlap = _lorentz_overlap(previous_mode, previous_mode, dxes)
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 self-overlap is zero')
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))
@ -166,6 +190,8 @@ def _section_branches(
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)
@ -203,7 +229,37 @@ def _section_branches(
if len(backward_wavenumbers) != len(wavenumbers):
raise ValueError('forward and backward mode counts must match')
return z_coord, forward_modes, wavenumbers, backward_modes, backward_wavenumbers, shape
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(
@ -212,6 +268,7 @@ def _validate_taper_sections(
) -> 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,
]:
@ -220,12 +277,14 @@ def _validate_taper_sections(
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, shape = _section_branches(
z_coord, forward_modes, forward_wavenumbers, backward_modes, backward_wavenumbers, dual_modes, dual_backward_modes, shape = _section_branches(
section,
index,
expected_count,
@ -236,21 +295,26 @@ def _validate_taper_sections(
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_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_wavenumbers, expected_count
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],
@ -261,7 +325,7 @@ def _taper_interval_generator(
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_modes):
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)):
@ -294,11 +358,12 @@ def _abcd_to_s(
def get_tr(
ehLs: Sequence[Sequence[vcfdfield2]],
ehLs: mode_seq,
wavenumbers_L: wavenumber_seq,
ehRs: Sequence[Sequence[vcfdfield2]],
ehRs: mode_seq,
wavenumbers_R: wavenumber_seq,
dxes: dx_lists2_t,
dual_ehLs: mode_seq | None = None,
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
"""
Compute left-incident transmission and reflection matrices.
@ -309,6 +374,8 @@ def get_tr(
ehRs: Right-port modes as `(E, H)` field pairs.
wavenumbers_R: Propagation constants for `ehRs`.
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:
`(T12, R12)` where columns index left-incident modes and rows index
@ -322,6 +389,8 @@ def get_tr(
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:
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)
nR = len(wavenumbers_R)
@ -330,11 +399,12 @@ def get_tr(
B11 = numpy.zeros((nL,), dtype=complex)
for ll in range(nL):
eL, hL = ehLs[ll]
B11[ll] = inner_product(eL, hL, dxes=dxes, conj_h=False)
eP, hP = projection_ehLs[ll]
B11[ll] = inner_product(eL, hP, dxes=dxes, conj_h=False)
for rr in range(nR):
eR, hR = ehRs[rr]
A12[ll, rr] = inner_product(eL, hR, dxes=dxes, conj_h=False) # TODO optimize loop?
A21[ll, rr] = inner_product(eR, hL, dxes=dxes, conj_h=False)
A12[ll, rr] = inner_product(eP, hR, dxes=dxes, conj_h=False) # TODO optimize loop?
A21[ll, rr] = inner_product(eR, hP, dxes=dxes, conj_h=False)
# tt0 = 2 * numpy.linalg.pinv(A21 + numpy.conj(A12))
tt0, _resid, _rank, _sing = numpy.linalg.lstsq(A21 + A12, numpy.diag(2 * B11), rcond=None)
@ -351,10 +421,12 @@ def get_tr(
def get_abcd(
ehLs: Sequence[Sequence[vcfdfield2]],
ehLs: mode_seq,
wavenumbers_L: wavenumber_seq,
ehRs: Sequence[Sequence[vcfdfield2]],
ehRs: mode_seq,
wavenumbers_R: wavenumber_seq,
dual_ehLs: mode_seq | None = None,
dual_ehRs: mode_seq | None = None,
**kwargs,
) -> sparse.sparray:
"""
@ -367,8 +439,8 @@ def get_abcd(
convention.
"""
t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, **kwargs)
t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, **kwargs)
t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dual_ehLs=dual_ehLs, **kwargs)
t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, dual_ehLs=dual_ehRs, **kwargs)
t21i = numpy.linalg.pinv(t21)
A = t12 - r21 @ t21i @ r12
B = r21 @ t21i
@ -384,12 +456,14 @@ def get_abcd(
def get_s(
ehLs: Sequence[Sequence[vcfdfield2]],
ehLs: mode_seq,
wavenumbers_L: wavenumber_seq,
ehRs: Sequence[Sequence[vcfdfield2]],
ehRs: mode_seq,
wavenumbers_R: wavenumber_seq,
force_nogain: bool = False,
force_reciprocal: bool = False,
dual_ehLs: mode_seq | None = None,
dual_ehRs: mode_seq | None = None,
**kwargs,
) -> NDArray[numpy.complex128]:
"""
@ -404,9 +478,11 @@ def get_s(
scattering matrix to at most one.
force_reciprocal: If `True`, symmetrize the assembled matrix as
`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, **kwargs)
t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, **kwargs)
t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dual_ehLs=dual_ehLs, **kwargs)
t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, dual_ehLs=dual_ehRs, **kwargs)
ss = numpy.block([[r12, t12],
[t21, r21]])
@ -437,6 +513,9 @@ def get_taper_abcd(
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`.
@ -459,13 +538,14 @@ def get_taper_abcd(
if max_step is not None and max_step <= 0:
raise ValueError('max_step must be positive')
z_coords, branch_modes, branch_wavenumbers, n_modes = _validate_taper_sections(sections, dxes)
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],
@ -494,7 +574,7 @@ def get_taper_s(
The returned matrix uses the same ordering as `get_s(...)`:
`[[R12, T12], [T21, R21]]`.
"""
_z_coords, _branch_modes, _branch_wavenumbers, n_modes = _validate_taper_sections(sections, dxes)
_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)