[eme] characterize orientation bias under modal truncation

Compare mixed and Galerkin testing against exact polarization/Fresnel interfaces and independent driven FDFD solves. Show complete-basis recovery, omitted-channel power, and the limits of longitudinal refinement, orientation averaging, and Euclidean least squares. Keep the default formulation unchanged and document its approximation limits.

Validation: 1689 tests passed, 584 skipped; Ruff and documentation build passed.
This commit is contained in:
Forgejo Actions 2026-10-18 23:21:29 -07:00
commit a08e9ce71b
3 changed files with 280 additions and 0 deletions

View file

@ -89,3 +89,65 @@ devices. Singular interface and cascade systems also raise contextual errors
rather than silently substituting pseudoinverses.
::: meanas.fdfd.eme
## Orientation bias under modal truncation
Mixed testing is a projected approximation: H continuity is tested on the left
and E continuity on the right. Swapping the ports changes the testing spaces
when the retained modal subspaces are incomplete. This effect survives checks
of reciprocity and unitarity.
A reproducible Maxwell counterexample is a uniform isotropic medium with two
degenerate linear polarizations. Keep one polarization on each port, rotated
so their overlap is `a=0.8`. There is no material interface: true reflection is
zero and transmission into the retained receiver is `a`. The other polarization
carries the remaining transmitted power, `1-a**2=0.36`.
| Calculation | R_left | R_right | T_left_to_right | T_right_to_left |
| --- | ---: | ---: | ---: | ---: |
| Exact full basis, then restrict ports | 0 | 0 | 0.8 | 0.8 |
| Truncated mixed | 0.219512 | -0.219512 | 0.975610 | 0.975610 |
| Swap ports, mixed, then reorder | -0.219512 | 0.219512 | 0.975610 | 0.975610 |
| Truncated left Galerkin | 0 | 0 | 1.25 | 0.8 |
| Euclidean E/H least squares | 0 | 0 | 0.8 | 0.8 |
An independent driven 3D FDFD solve with PML agrees with the exact reference
from either incidence direction: transmission is 0.8 within `1e-12`, with
reflection below `2e-8` in the test fixture. Keeping both polarizations makes
mixed testing match the exact complete scattering matrix and restores
port-relabeling covariance.
For the single retained polarization, mixed testing yields
`r=(1-a**2)/(1+a**2)` and `t=2*a/(1+a**2)`. The spurious reflection is quadratic
in a small angle between adjacent retained polarizations. However, longitudinal
refinement alone is insufficient: rotating a single retained vector through a
uniform degenerate medium makes the sampled chain approach artificial following
of that vector (`|T|` approaches 1), while the physical endpoint overlap remains
0.8. Keeping the complete degenerate block recovers the physical result at every
sampling density tested.
Two tempting corrections are insufficient. Averaging the two port orientations
cancels reflection in this example but leaves transmission at 0.975610. Plain
Euclidean E/H least squares gives the correct uniform-medium result, but is not
a general replacement. A second fixture uses exact degenerate modes of diagonal
positive epsilon/mu tensors, with electric scaling `(1,2)` on the left and
`(1,3)` on the right and a 30-degree polarization rotation on both ports. After
single-mode truncation, least squares has unequal directional transmissions
(approximately 0.850839 and 1.092163) and a maximum singular value of 1.104947.
The complete Fresnel solution is reciprocal and lossless.
The mixed default remains unchanged. Retain complete coupled/degenerate blocks
when the geometry can mix them, check modal-basis convergence as well as section
sampling, and compare against independent full-field results. Neither orientation
averaging nor gain/reciprocity postprocessing recovers omitted-channel physics.
Orientation discrepancy is a useful diagnostic, not a bound on transmission
error. These examples use real power-orthonormal propagating modes; for general
complex modes, the reciprocity constraint involves the modal orthogonality
matrix, as derived by [Svendsen, Haakestad, and Skaar](https://arxiv.org/abs/1301.2458).
Reproduce the analytic, full-basis, candidate-formulation, taper-refinement, and
driven-FDFD checks with:
```sh
OPENBLAS_NUM_THREADS=1 python -m pytest meanas/test/test_eme_orientation.py -q
```

View file

@ -512,6 +512,12 @@ def get_s(
use the primal fields. Wavenumbers label the modes and are not used in the
interface continuity equations. Forward modes imply backward (E, -H).
Mixed testing assigns different test families to the two ports. Incomplete
modal subspaces can therefore give different approximations after a port
exchange, even when each result is reciprocal and unitary. Establish basis
convergence, especially within coupled or degenerate blocks; do not infer
physical accuracy from these matrix constraints alone.
``force_nogain`` caps Euclidean singular values; ``force_reciprocal``
symmetrizes S. These optional numerical projections are not general power
or reciprocity tests for open, evanescent, or non-power-normalized bases.
@ -734,6 +740,8 @@ def get_taper_s(
Each interval propagates half its length in either adjacent basis. Accuracy
is controlled by section sampling and basis/PML convergence, not ODE
tolerances. Standard outgoing/incoming [left, right] ordering is used.
Longitudinal refinement alone cannot repair omitted coupled/degenerate
modes or remove the orientation bias of incomplete interface projections.
"""
prepared = prepare_taper(sections, dxes, formulation=formulation, measure=measure)
return evaluate_taper_s(prepared, force_nogain=force_nogain, force_reciprocal=force_reciprocal)

View file

@ -0,0 +1,210 @@
"""Characterize truncation bias against exact reciprocal Maxwell interfaces.
These tests intentionally document mixed/Galerkin projection limitations; they
are not passivity-enforcement tests. Both complete modal bases and an independent
driven FDFD solve establish the reference for the truncated polarization case.
"""
import numpy as np
import pytest
from numpy.testing import assert_allclose
from scipy.sparse.linalg import spsolve
from ..fdmath import vec, unvec
from ..fdfd import eme, scpml, solvers, waveguide_2d, waveguide_3d
DXES = [[np.ones(1), np.ones(1)] for _ in range(2)]
def _rotation(angle):
return np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])
def _polarizations(angle, scales=(1., 1.)):
"""Two power-orthonormal signed-beta=1 modes on a one-cell transverse grid.
E_xy = sqrt(2) diag(scales) Q, and J^-1 H_xy = sqrt(2) diag(1/scales) Q.
They solve Maxwell with diagonal epsilon=(1/sx^2,1/sy^2,1) and
mu=(sy^2,sx^2,1) at omega=1, including nonparallel E/H traces.
"""
rotation = _rotation(angle)
electric = np.diag(scales) @ rotation
magnetic = np.diag(1 / np.asarray(scales)) @ rotation
return [
(np.sqrt(2) * np.array([electric[0, i], electric[1, i], 0], complex),
np.sqrt(2) * np.array([-magnetic[1, i], magnetic[0, i], 0], complex))
for i in range(2)
]
def _scattering(left, right, formulation='mixed'):
return eme.get_s(left, np.ones(len(left)), right, np.ones(len(right)), DXES, formulation=formulation)
def _reorder_ports(matrix):
n = matrix.shape[0] // 2
order = [*range(n, 2*n), *range(n)]
return matrix[np.ix_(order, order)]
def _fresnel_reference(left_angle, right_angle, left_scales=(1., 1.), right_scales=(1., 1.)):
"""Independent exact two-polarization interface in the supplied port bases."""
yl, yr = 1 / np.asarray(left_scales)**2, 1 / np.asarray(right_scales)**2
reflection = np.diag((yl - yr) / (yl + yr))
transmission = np.diag(2 * np.sqrt(yl * yr) / (yl + yr))
ql, qr = _rotation(left_angle), _rotation(right_angle)
return np.block([[ql.T @ reflection @ ql, ql.T @ transmission @ qr],
[qr.T @ transmission @ ql, -qr.T @ reflection @ qr]])
def _trace_least_squares(left, right):
"""Research comparator: unweighted Euclidean tangential E/H least squares.
This is deliberately not a public formulation: correcting the isotropic
polarization example does not establish reciprocity or passivity generally.
"""
el = np.column_stack([e[:2] for e, _h in left])
hl = np.column_stack([h[:2] for _e, h in left])
er = np.column_stack([e[:2] for e, _h in right])
hr = np.column_stack([h[:2] for _e, h in right])
outgoing = np.block([[el, -er], [-hl, -hr]])
incoming = np.block([[-el, er], [-hl, -hr]])
return np.linalg.lstsq(outgoing, incoming, rcond=None)[0]
@pytest.mark.parametrize('overlap', [.5, .8, .95])
def test_truncated_mixed_polarizations_show_orientation_bias_despite_unitarity(overlap):
angle = np.arccos(overlap)
left, right = _polarizations(0)[:1], _polarizations(angle)[:1]
result = _scattering(left, right)
reversed_result = _reorder_ports(_scattering(right, left))
reflection = (1-overlap**2) / (1+overlap**2)
transmission = 2*overlap / (1+overlap**2)
assert_allclose(result, [[reflection, transmission], [transmission, -reflection]], atol=1e-14)
assert_allclose(reversed_result, [[-reflection, transmission], [transmission, reflection]], atol=1e-14)
assert_allclose(result.T @ result, np.eye(2), atol=1e-14)
assert_allclose(result, result.T, atol=1e-14)
assert_allclose(np.linalg.norm(result-reversed_result), 2*np.sqrt(2)*reflection, atol=1e-14)
@pytest.mark.parametrize(('left_scales', 'right_scales'), [((1., 1.), (1., 1.)), ((1., 2.), (1., 3.))])
def test_complete_maxwell_bases_recover_fresnel_and_port_relabeling(left_scales, right_scales):
left_angle, right_angle = np.pi/6, np.arccos(.8)
left, right = _polarizations(left_angle, left_scales), _polarizations(right_angle, right_scales)
# Establish that the synthetic fields are actual degenerate Maxwell modes,
# not arbitrary profiles or mixed vectors with different eigenvalues.
for modes, scales in ((left, left_scales), (right, right_scales)):
sx, sy = scales
epsilon, mu = np.array([1/sx**2, 1/sy**2, 1]), np.array([sy**2, sx**2, 1])
operator = waveguide_2d.operator_e(1, DXES, epsilon, mu)
for e, h in modes:
assert_allclose(operator @ e[:2], e[:2], atol=1e-14)
assert_allclose(waveguide_2d.exy2h(1, 1, DXES, epsilon, mu) @ e[:2], h, atol=1e-14)
gram = [[waveguide_2d.lorentz_overlap(a, b, DXES) for b in modes] for a in modes]
assert_allclose(gram, np.eye(2), atol=1e-14)
result = _scattering(left, right)
expected = _fresnel_reference(left_angle, right_angle, left_scales, right_scales)
assert_allclose(result, expected, atol=1e-14)
assert_allclose(_reorder_ports(_scattering(right, left)), expected, atol=1e-14)
assert_allclose(result.T @ result, np.eye(4), atol=1e-14)
assert_allclose(result, result.T, atol=1e-14)
def test_missing_polarization_accounts_for_retained_submatrix_power_deficit():
overlap = .8
full = _scattering(_polarizations(0), _polarizations(np.arccos(overlap)))
retained = full[np.ix_([0, 2], [0, 2])]
assert_allclose(retained, [[0, overlap], [overlap, 0]], atol=1e-14)
assert_allclose(abs(full[3, 0])**2, 1-overlap**2, atol=1e-14)
assert_allclose(np.sum(abs(full[:, 0])**2), 1, atol=1e-14)
assert_allclose(np.sum(abs(retained[:, 0])**2), overlap**2, atol=1e-14)
def test_posthoc_orientation_averaging_does_not_recover_physical_transmission():
overlap = .8
left, right = _polarizations(0)[:1], _polarizations(np.arccos(overlap))[:1]
averaged = .5 * (_scattering(left, right) + _reorder_ports(_scattering(right, left)))
assert_allclose(averaged[0, 0], 0, atol=1e-14)
assert_allclose(averaged[1, 1], 0, atol=1e-14)
assert abs(averaged[1, 0] - overlap) > .17
def test_galerkin_testing_does_not_resolve_truncated_polarization_error():
overlap = .8
left, right = _polarizations(0)[:1], _polarizations(np.arccos(overlap))[:1]
result = _scattering(left, right, 'galerkin')
assert_allclose(result, [[0, overlap], [1/overlap, 0]], atol=1e-14)
assert not np.allclose(result, result.T)
def test_trace_least_squares_recovers_uniform_reference_but_is_not_a_general_fix():
overlap = .8
left, right = _polarizations(0)[:1], _polarizations(np.arccos(overlap))[:1]
assert_allclose(_trace_least_squares(left, right), [[0, overlap], [overlap, 0]], atol=1e-14)
angle = np.pi/6
left, right = _polarizations(angle, (1., 2.)), _polarizations(angle, (1., 3.))
reference = _fresnel_reference(angle, angle, (1., 2.), (1., 3.))
assert_allclose(_trace_least_squares(left, right), reference, atol=1e-14)
truncated = _trace_least_squares(left[:1], right[:1])
# All traces come from the exact reciprocal, lossless Maxwell bases above.
# Euclidean residual minimization nevertheless violates both constraints
# after truncation. Port-relabeling covariance alone is not sufficient.
assert np.linalg.norm(truncated-truncated.T) > .3
assert np.linalg.svd(truncated, compute_uv=False).max() > 1.10
assert_allclose(_reorder_ports(_trace_least_squares(right[:1], left[:1])), truncated, atol=1e-14)
@pytest.mark.parametrize('side', [0, 1])
def test_driven_fdfd_confirms_uniform_polarization_reference(side):
omega, thickness = np.pi/8, 24
size = 2*thickness + 64
physical = [[np.ones(1), np.ones(1), np.ones(size)] for _ in range(2)]
epsilon = np.ones((3, 1, 1, size))
solve_widths = physical
for polarity in (-1, 1):
solve_widths = scpml.stretch_with_scpml(solve_widths, 2, polarity, omega, thickness=thickness)
overlap = .8
profiles = [np.array([1., 0.]), np.array([overlap, np.sqrt(1-overlap**2)])]
polarity = 1 if side == 0 else -1
source_index = thickness+4 if side == 0 else thickness+52
slices = (slice(None), slice(None), slice(source_index, source_index+1))
mode = waveguide_3d.mode_from_2d(profiles[side], omega, omega=omega, dxes=physical,
axis=2, polarity=polarity, slices=slices, epsilon=epsilon)
current = waveguide_3d.compute_source(mode['E'], mode['wavenumber'], omega, physical, 2, polarity, slices, epsilon)
field = unvec(solvers.generic(omega, solve_widths, vec(current), vec(epsilon), matrix_solver=spsolve), epsilon.shape[1:])
left_index, right_index = thickness+12, thickness+40
beta = 2*np.arcsin(omega/2)
phase = np.exp(-1j*beta)
def waves(index):
e0, e1 = field[:2, 0, 0, index], field[:2, 0, 0, index+1]
forward = (e1 - e0/phase) / (phase - 1/phase)
return forward, e0-forward
fl, bl = waves(left_index)
fr, br = waves(right_index)
incoming = np.dot(profiles[side], fl if side == 0 else br)
reflected = np.dot(profiles[side], bl if side == 0 else fr) / incoming
transmitted = np.dot(profiles[1-side], fr if side == 0 else bl) / incoming
transmitted *= np.exp(1j*beta*(right_index-left_index))
assert abs(reflected) < 5e-8
assert_allclose(transmitted, overlap, atol=1e-12)
def test_longitudinal_refinement_cannot_repair_an_omitted_degenerate_polarization():
angle = np.arccos(.8)
reflection_magnitudes = []
for count in (3, 9, 33):
complete = [eme.ModeSection(z, _polarizations(angle*z), [1., 1.]) for z in np.linspace(0, 1, count)]
truncated = [eme.ModeSection(section.z, section.modes[:1], [1.]) for section in complete]
full = eme.get_taper_s(complete, DXES)
expected = _fresnel_reference(0, angle).astype(complex) * np.exp(-1j)
assert_allclose(full, expected, atol=1e-13)
small = eme.get_taper_s(truncated, DXES)
reflection_magnitudes.append(abs(small[0, 0]))
# There is no material taper: the basis is merely rotated within a
# degenerate subspace. Restricting the exact result gives |T|=.8, while
# the truncated chain approaches artificial adiabatic following, |T|=1.
assert abs(small[1, 0]) > .99
assert reflection_magnitudes[-1] < reflection_magnitudes[0] / 10
assert_allclose(abs(expected[2, 0]), .8, atol=1e-14)