Status: proposal / design note
Area: src/anisotropic_averaging.cpp, src/susceptibility.cpp, src/update_pols.cpp
Motivation: permanently fix the anisotropic-Lorentzian boundary instability that #666 worked around with a runtime guard, and remove a known accuracy limitation.
Background: the #666 guard
lorentzian_susceptibility::update_P (src/susceptibility.cpp) guards its anisotropic (2×2 and 3×3) branches with a per-point test that skips the polarization update where the diagonal sigma is zero:
|
// s[i] != 0 check is a bit of a hack to work around |
|
// some instabilities that occur near the boundaries |
|
// of materials; see PR #666 |
|
if (s[i] != 0) { |
|
realnum pcur = p[i]; |
|
p[i] = gamma1inv * (pcur * (2 - omega0dtsqr_denom) - gamma1 * pp[i] + |
|
omega0dtsqr * (s[i] * w[i] + OFFDIAG(s1, w1, is1, is) + |
|
OFFDIAG(s2, w2, is2, is))); |
|
pp[i] = pcur; |
|
} |
It exists to suppress an instability "near the boundaries of materials."
Root cause (empirically established)
A controlled 2D TE reproducer (anisotropic Lorentzian medium, Gaussian pulse, no PML, watch total energy) isolates the trigger precisely:
Off-diagonal sigma support vs. diagonal support |
No-guard result |
| Off-diagonal exceeds diagonal |
diverges |
| Off-diagonal strictly inside diagonal |
stable (decays) |
With the guard, the diverging case becomes stable, and for the well-posed case the guard is a no-op (bit-identical results).
The mechanism:
- The off-diagonal coupling in
update_P uses the 4-point OFFDIAG averaging stencil, which reads sigma/field at i and i+is (one cell along the component's own direction).
- The dispersive
sigma is point-sampled in structure_chunk::add_susceptibility (src/anisotropic_averaging.cpp) — it is not subpixel-averaged. At a material boundary the off-diagonal sigma is therefore nonzero one stencil-step beyond where the diagonal sigma has dropped to zero.
- At such a point
P_c is driven with no diagonal sigma to anchor it. The local discrete susceptibility tensor is effectively [[0, σ], [σ, 0]], whose eigenvalues are ±σ — not positive-semidefinite, i.e. non-passive — so it pumps energy and the simulation diverges.
By contrast, the instantaneous permittivity chi1inv is built with subpixel (Kottke) averaging (structure_chunk::set_chi1inv → material_function::eff_chi1inv_row), which yields a smooth, PSD-consistent tensor whose diagonal and off-diagonal taper together at boundaries. That path is stable. The dispersive sigma lacks this treatment — that is the root cause.
A naive construction-time fix ("zero off-diagonal sigma where the diagonal is zero, same pixel") was prototyped and fails: a diagnostic showed it touches zero points, because the mismatch lives one stencil-step (i+is) away, not at the same pixel.
Proposed fix
Give the dispersive susceptibility tensor the same subpixel averaging the instantaneous tensor already receives, so diagonal and off-diagonal sigma taper together (PSD-preserving) and the off-diagonal can never "stick out" past the diagonal. Then the update_P guard becomes unnecessary and can be removed, and dispersive interfaces gain the same second-order accuracy that non-dispersive interfaces already have.
Approach
- Reuse the Kottke machinery.
eff_chi1inv_row (and the libctl/MPB-backed override) computes the effective inverse-permittivity tensor row for a pixel from the material normal vector and the harmonic/arithmetic mean across the interface. The dispersive sigma tensor should be averaged with the same normal-vector + projection construction so the smoothed tensor is symmetric PSD by construction (the projection from P·a + (I-P)·b preserves PSD when a,b ≥ 0).
- New material-function hook. Add an
eff_sigma_row(component c, double sigrow[3], const volume &v, ...) analogous to eff_chi1inv_row, returning the subpixel-averaged sigma row for the pixel volume v. Default implementation: numerical surface/volume average mirroring eff_chi1inv_row: the libctl interface (python/meep/geom.py / libctlgeom) can override with a semi-analytic average, as it already does for eff_chi1inv_row.
- Wire it into
add_susceptibility. In structure_chunk::add_susceptibility (src/anisotropic_averaging.cpp), replace the point-sampling loop (the sigma_row calls at here and here - shift1) with calls to eff_sigma_row over the pixel volume when subpixel averaging is enabled, falling back to point sampling otherwise (for backward-compatible, averaging disabled runs).
- Remove the guard. Once
sigma is PSD-consistent at boundaries, delete the s[i] != 0 guard from lorentzian_susceptibility::update_P. Keep the non-PSD-tensor warning (added in a separate PR) for genuinely non-passive user input.
Subtleties
- Per-direction storage.
sigma[c][d] stores one scalar per (component, direction); the averaged tensor must be expressible in this staggered layout. The diagonal and off-diagonal are sampled at different Yee points, so the averaging must respect the existing half-pixel shift1 convention used for off-diagonals.
- Drude / multi-pole / gyrotropic. The averaging applies to the
sigma prefactor only (the per-pole omega_0, gamma are scalars), so Lorentzian, Drude, and noisy-Lorentzian share the treatment. Gyrotropic media use a separate gyro_tensor and a different update; out of scope.
- Backward compatibility. Results for anisotropic dispersive media at boundaries will change (they become more accurate). Gate behind the existing
use_anisotropic_averaging flag so users can reproduce old behavior; document the change.
Validation plan.
- Stability: the reproducer above (off-diagonal exceeding diagonal) must remain stable without the guard once averaging is on.
- No regression:
python/tests/test_faraday_rotation.py, test_material_dispersion.py, test_dispersive_eigenmode.py, test_multilevel_atom.py; C++ tests/pml.cpp (offdiag case).
- Accuracy / convergence: add a convergence test for an anisotropic dispersive interface (e.g. transmission through a rotated Lorentzian slab) showing second-order error vs. resolution with averaging on, first-order with it off — mirroring the non-dispersive subpixel tests.
- Energy conservation: a closed cavity with a lossless (
gamma=0) anisotropic dispersive inclusion should conserve energy (no secular growth) without the guard.
Effort / risk
Substantial: it introduces a new averaging path for dispersive materials (a long-standing Meep limitation, not just a bug fix), touches the C++ core and the libctl/Python material interface, and changes numerical results for anisotropic dispersive media. Recommend landing behind the averaging flag with the convergence + energy tests above, and keeping the guard until averaging is validated to make it removable.
Near-term status
Until this lands, the repository ships:
- the branchless guard (to be added in a separate PR; efficient, correct passitivity enforcement; no-op for well-posed media), and
- a non-PSD
sigma warning in structure::add_susceptibility that flags genuinely non-passive user tensors (which neither the guard nor subpixel averaging can fix).
Status: proposal / design note
Area:
src/anisotropic_averaging.cpp,src/susceptibility.cpp,src/update_pols.cppMotivation: permanently fix the anisotropic-Lorentzian boundary instability that #666 worked around with a runtime guard, and remove a known accuracy limitation.
Background: the #666 guard
lorentzian_susceptibility::update_P(src/susceptibility.cpp) guards its anisotropic (2×2 and 3×3) branches with a per-point test that skips the polarization update where the diagonalsigmais zero:meep/src/susceptibility.cpp
Lines 229 to 238 in ea11e6c
It exists to suppress an instability "near the boundaries of materials."
Root cause (empirically established)
A controlled 2D TE reproducer (anisotropic Lorentzian medium, Gaussian pulse, no PML, watch total energy) isolates the trigger precisely:
sigmasupport vs. diagonal supportWith the guard, the diverging case becomes stable, and for the well-posed case the guard is a no-op (bit-identical results).
The mechanism:
update_Puses the 4-pointOFFDIAGaveraging stencil, which readssigma/field atiandi+is(one cell along the component's own direction).sigmais point-sampled instructure_chunk::add_susceptibility(src/anisotropic_averaging.cpp) — it is not subpixel-averaged. At a material boundary the off-diagonalsigmais therefore nonzero one stencil-step beyond where the diagonalsigmahas dropped to zero.P_cis driven with no diagonalsigmato anchor it. The local discrete susceptibility tensor is effectively[[0, σ], [σ, 0]], whose eigenvalues are±σ— not positive-semidefinite, i.e. non-passive — so it pumps energy and the simulation diverges.By contrast, the instantaneous permittivity
chi1invis built with subpixel (Kottke) averaging (structure_chunk::set_chi1inv→material_function::eff_chi1inv_row), which yields a smooth, PSD-consistent tensor whose diagonal and off-diagonal taper together at boundaries. That path is stable. The dispersivesigmalacks this treatment — that is the root cause.A naive construction-time fix ("zero off-diagonal
sigmawhere the diagonal is zero, same pixel") was prototyped and fails: a diagnostic showed it touches zero points, because the mismatch lives one stencil-step (i+is) away, not at the same pixel.Proposed fix
Give the dispersive susceptibility tensor the same subpixel averaging the instantaneous tensor already receives, so diagonal and off-diagonal
sigmataper together (PSD-preserving) and the off-diagonal can never "stick out" past the diagonal. Then theupdate_Pguard becomes unnecessary and can be removed, and dispersive interfaces gain the same second-order accuracy that non-dispersive interfaces already have.Approach
eff_chi1inv_row(and the libctl/MPB-backed override) computes the effective inverse-permittivity tensor row for a pixel from the material normal vector and the harmonic/arithmetic mean across the interface. The dispersivesigmatensor should be averaged with the same normal-vector + projection construction so the smoothed tensor is symmetric PSD by construction (the projection fromP·a + (I-P)·bpreserves PSD whena,b ≥ 0).eff_sigma_row(component c, double sigrow[3], const volume &v, ...)analogous toeff_chi1inv_row, returning the subpixel-averagedsigmarow for the pixel volumev. Default implementation: numerical surface/volume average mirroringeff_chi1inv_row: the libctl interface (python/meep/geom.py/ libctlgeom) can override with a semi-analytic average, as it already does foreff_chi1inv_row.add_susceptibility. Instructure_chunk::add_susceptibility(src/anisotropic_averaging.cpp), replace the point-sampling loop (thesigma_rowcalls athereandhere - shift1) with calls toeff_sigma_rowover the pixel volume when subpixel averaging is enabled, falling back to point sampling otherwise (for backward-compatible, averaging disabled runs).sigmais PSD-consistent at boundaries, delete thes[i] != 0guard fromlorentzian_susceptibility::update_P. Keep the non-PSD-tensor warning (added in a separate PR) for genuinely non-passive user input.Subtleties
sigma[c][d]stores one scalar per (component, direction); the averaged tensor must be expressible in this staggered layout. The diagonal and off-diagonal are sampled at different Yee points, so the averaging must respect the existing half-pixelshift1convention used for off-diagonals.sigmaprefactor only (the per-poleomega_0, gammaare scalars), so Lorentzian, Drude, and noisy-Lorentzian share the treatment. Gyrotropic media use a separategyro_tensorand a different update; out of scope.use_anisotropic_averagingflag so users can reproduce old behavior; document the change.Validation plan.
python/tests/test_faraday_rotation.py,test_material_dispersion.py,test_dispersive_eigenmode.py,test_multilevel_atom.py; C++tests/pml.cpp(offdiag case).gamma=0) anisotropic dispersive inclusion should conserve energy (no secular growth) without the guard.Effort / risk
Substantial: it introduces a new averaging path for dispersive materials (a long-standing Meep limitation, not just a bug fix), touches the C++ core and the libctl/Python material interface, and changes numerical results for anisotropic dispersive media. Recommend landing behind the averaging flag with the convergence + energy tests above, and keeping the guard until averaging is validated to make it removable.
Near-term status
Until this lands, the repository ships:
sigmawarning instructure::add_susceptibilitythat flags genuinely non-passive user tensors (which neither the guard nor subpixel averaging can fix).