"""Dispersive material models for FDTDX.
Provides a generic Auxiliary Differential Equation (ADE) dispersion
abstraction for linear materials. The first concrete pole types are
Lorentz and Drude, combined freely as a "Drude-Lorentz" model.
Physics
-------
Each pole contributes a 2nd-order ODE for the normalized polarization
``p = P / eps_0`` (same units as E):
.. math::
\\ddot{p}_p + \\gamma_p \\dot{p}_p + \\omega_{0,p}^2 p_p = K_p E
Lorentz pole (resonance :math:`\\omega_0`, damping :math:`\\gamma`,
strength :math:`\\Delta\\varepsilon`):
.. math::
\\chi_p(\\omega) = \\frac{\\Delta\\varepsilon \\cdot \\omega_0^2}{\\omega_0^2 - \\omega^2 - i\\gamma\\omega}
Drude pole (plasma frequency :math:`\\omega_p`, damping :math:`\\gamma`;
special case of Lorentz with :math:`\\omega_0 = 0`):
.. math::
\\chi_p(\\omega) = -\\frac{\\omega_p^2}{\\omega^2 + i\\gamma\\omega}
The unified pole parameterization stores ``(omega_0, gamma, coupling_sq)``
where ``coupling_sq`` is the effective squared coupling frequency
:math:`K = \\Delta\\varepsilon \\omega_0^2` (Lorentz) or :math:`\\omega_p^2`
(Drude), both in (rad/s)^2.
Discrete update
---------------
Central differences at integer time ``n``:
.. math::
p_p^{n+1} = c_1 p_p^{n} + c_2 p_p^{n-1} + c_3 E^{n}
with coefficients derived from the unified pole parameters and time
step ``dt``:
.. math::
c_1 = \\frac{2 - \\omega_0^2 \\Delta t^2}{1 + \\gamma \\Delta t / 2}, \\quad
c_2 = -\\frac{1 - \\gamma \\Delta t / 2}{1 + \\gamma \\Delta t / 2}, \\quad
c_3 = \\frac{K \\Delta t^2}{1 + \\gamma \\Delta t / 2}
**Forward unit-circle (Jury) stability** constrains the coefficients and is
enforced in :func:`compute_pole_coefficients_per_axis` (only on axes where the
pole actually couples, since a zero-coupling axis keeps its polarization
identically zero). The roots of :math:`z^2 - c_1 z - c_2 = 0` lie inside the
unit circle iff :math:`|c_2| < 1` *and* :math:`|c_1| < 1 - c_2`. The first
holds for every :math:`\\gamma \\Delta t > 0` (:math:`c_2 = 0` at
:math:`\\gamma \\Delta t = 2` and :math:`|c_2| \\to 1` only as
:math:`\\gamma \\Delta t \\to 0` or :math:`\\infty`), so it is not the binding
constraint. The second is algebraically equivalent to
:math:`\\omega_0 \\Delta t < 2` (independent of :math:`\\gamma`), which is
therefore the stability bound.
Anisotropic (per-axis) dispersion
---------------------------------
Every pole parameter accepts either a scalar (isotropic, applied to all
three axes) or a 3-tuple ``(x, y, z)`` giving a different value per grid
axis. This yields a diagonally anisotropic susceptibility tensor
:math:`\\chi(\\omega) = \\mathrm{diag}(\\chi_x, \\chi_y, \\chi_z)` — enough to
model uniaxial/biaxial crystals and hyperbolic media (e.g. hBN) whose
optical axes align with the grid. A pole that only acts on one axis is
expressed by zeroing its strength on the others, e.g.
``LorentzPole(resonance_frequency=w0, damping=g, delta_epsilon=(2.25, 0.0, 0.0))``:
with zero coupling the polarization on that axis stays identically zero.
Oriented (off-diagonal) dispersion
----------------------------------
A pole may additionally carry an ``orientation`` unit vector ``u``: it then
acts as a single 1D oscillator along ``u`` and contributes the coupling
tensor :math:`K\\, u u^T` — off-diagonal for non-axis-aligned directions.
This models rotated/tilted crystals and monoclinic media (shear phonon
polaritons), where each IR-active phonon oscillates along its own,
generally non-orthogonal, direction. :meth:`DispersionModel.rotated`
converts a per-axis model into oriented poles for the common case of a
crystal rotated relative to the grid. Oriented dispersion runs through the
fully anisotropic update path.
Gradients
---------
Dispersive simulations currently support only the ``checkpointed`` gradient
method; the ``reversible`` method rejects them (reversing the ADE polarization
recurrence is under active development).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
import jax
import jax.numpy as jnp
import numpy as np
from fdtdx.constants import eps0
from fdtdx.core.jax.pytrees import TreeClass, autoinit, frozen_field
def _broadcast_axis_param(value: float | tuple) -> tuple:
"""Normalize a pole parameter to a per-axis 3-tuple ``(x, y, z)``.
Scalars are broadcast to all three axes; 3-tuples pass through unchanged.
"""
if isinstance(value, tuple):
if len(value) != 3:
raise ValueError(
f"Per-axis pole parameters must be a scalar or a 3-tuple (x, y, z), got a tuple of length {len(value)}."
)
return value
return (value, value, value)
def _is_uniform(axes: tuple) -> bool:
return bool(axes[0] == axes[1] == axes[2])
def _as_rotation_matrix(rotation: tuple) -> np.ndarray:
"""Build and validate a 3x3 rotation matrix from a nested tuple or Euler angles."""
if isinstance(rotation, tuple) and len(rotation) == 3 and not any(isinstance(v, tuple) for v in rotation):
alpha, beta, gamma = (float(v) for v in rotation)
ca, sa = np.cos(alpha), np.sin(alpha)
cb, sb = np.cos(beta), np.sin(beta)
cg, sg = np.cos(gamma), np.sin(gamma)
rx = np.array([[1.0, 0.0, 0.0], [0.0, ca, -sa], [0.0, sa, ca]])
ry = np.array([[cb, 0.0, sb], [0.0, 1.0, 0.0], [-sb, 0.0, cb]])
rz = np.array([[cg, -sg, 0.0], [sg, cg, 0.0], [0.0, 0.0, 1.0]])
r_mat = rz @ ry @ rx
else:
r_mat = np.asarray(rotation, dtype=np.float64)
if r_mat.shape != (3, 3):
raise ValueError(
f"rotation must be a 3x3 nested tuple or a 3-tuple of Euler angles, got shape {r_mat.shape}."
)
if not np.allclose(r_mat @ r_mat.T, np.eye(3), atol=1e-9) or not np.isclose(np.linalg.det(r_mat), 1.0, atol=1e-9):
raise ValueError("rotation must be a proper rotation matrix (orthogonal with determinant +1).")
return r_mat
def _signed_permutation(r_mat: np.ndarray, tol: float = 1e-12) -> tuple[int, int, int] | None:
"""Detect a signed axis permutation: returns ``perm`` with grid axis ``perm[a]``
receiving crystal axis ``a``, or ``None`` if the rotation is not a permutation."""
perm = []
for a in range(3):
col = r_mat[:, a]
nonzero = np.flatnonzero(np.abs(col) > tol)
if len(nonzero) != 1 or not np.isclose(abs(col[nonzero[0]]), 1.0, atol=tol):
return None
perm.append(int(nonzero[0]))
return (perm[0], perm[1], perm[2])
def _permute_pole_axes(p: "Pole", perm: tuple[int, int, int]) -> "Pole":
"""Remap a per-axis pole's parameters under a signed axis permutation (sign is
irrelevant: the coupling enters as ``u u^T``)."""
def _remap(value):
axes = _broadcast_axis_param(value)
out = [axes[0]] * 3
for a in range(3):
out[perm[a]] = axes[a]
return (out[0], out[1], out[2])
if isinstance(p, LorentzPole):
return LorentzPole(
resonance_frequency=_remap(p.resonance_frequency),
damping=_remap(p.damping),
delta_epsilon=_remap(p.delta_epsilon),
)
if isinstance(p, DrudePole):
return DrudePole(plasma_frequency=_remap(p.plasma_frequency), damping=_remap(p.damping))
raise TypeError(
f"Cannot rotate pole of type {type(p).__name__}; construct oriented poles directly for custom pole types."
)
def _oriented_pole_for_axis(p: "Pole", axis: int, direction: tuple[float, float, float]) -> "Pole":
"""Extract the 1D oscillator of a per-axis pole along ``axis`` as an oriented pole."""
if isinstance(p, LorentzPole):
w = _broadcast_axis_param(p.resonance_frequency)
g = _broadcast_axis_param(p.damping)
de = _broadcast_axis_param(p.delta_epsilon)
return LorentzPole(
resonance_frequency=float(w[axis]),
damping=float(g[axis]),
delta_epsilon=float(de[axis]),
orientation=direction,
)
if isinstance(p, DrudePole):
wp = _broadcast_axis_param(p.plasma_frequency)
g = _broadcast_axis_param(p.damping)
return DrudePole(plasma_frequency=float(wp[axis]), damping=float(g[axis]), orientation=direction)
raise TypeError(
f"Cannot rotate pole of type {type(p).__name__}; construct oriented poles directly for custom pole types."
)
[docs]
@autoinit
class Pole(TreeClass, ABC):
"""Abstract base class for a single 2nd-order ADE pole.
Concrete subclasses store physically-meaningful parameters
(e.g. ``delta_epsilon`` for Lorentz, ``omega_p`` for Drude) and
expose the unified ``(omega_0, gamma, coupling_sq)`` triplet the
FDTD loop needs via per-axis properties. New pole types can
subclass :class:`Pole` as long as they fit the 2nd-order
ODE form.
Every parameter may differ per grid axis (diagonally anisotropic
dispersion); the canonical accessors are the ``*_axes`` properties
returning ``(x, y, z)`` tuples. The scalar accessors (``omega_0`` etc.)
are a convenience for isotropic poles and raise for per-axis ones.
Alternatively a pole may carry an :attr:`orientation` unit vector,
turning it into a single 1D oscillator along that direction
(off-diagonal coupling tensor :math:`K\\, u u^T`).
"""
#: Optional oscillator direction ``u`` (normalized on construction).
#: ``None`` (default) applies the pole isotropically or per-axis. When
#: set, the pole is a single 1D oscillator along ``u`` with coupling
#: tensor ``K * u u^T``; all other pole parameters must be scalars.
orientation: tuple[float, float, float] | None = frozen_field(default=None)
def _validate_orientation(self):
"""Normalize and validate :attr:`orientation`. Concrete pole classes call
this from ``__post_init__`` (which ``autoinit`` only invokes when defined
directly on the class, not inherited)."""
if self.orientation is None:
return
vec = self.orientation
if not isinstance(vec, tuple) or len(vec) != 3:
raise ValueError(f"Pole orientation must be a 3-tuple (x, y, z), got {vec!r}.")
arr = np.asarray(vec, dtype=np.float64)
if not np.all(np.isfinite(arr)):
raise ValueError(f"Pole orientation components must be finite, got {vec!r}.")
scale = float(np.max(np.abs(arr)))
if scale == 0.0:
raise ValueError("Pole orientation must be a non-zero vector.")
# scale first so the squared terms cannot overflow for large components
scaled = arr / scale
norm = float(np.linalg.norm(scaled))
object.__setattr__(
self, "orientation", (float(scaled[0]) / norm, float(scaled[1]) / norm, float(scaled[2]) / norm)
)
for name in ("omega_0", "gamma", "coupling_sq"):
if not _is_uniform(getattr(self, f"{name}_axes")):
raise ValueError(
f"Oriented poles are single 1D oscillators and require scalar parameters, "
f"but '{name}' differs per axis. Use one oriented pole per direction instead."
)
def _uniform_or_raise(self, axes: tuple, name: str) -> float:
if not _is_uniform(axes):
raise ValueError(
f"{type(self).__name__} has per-axis parameters; use the per-axis "
f"accessor '{name}_axes' instead of the scalar '{name}'."
)
return axes[0]
@property
@abstractmethod
def omega_0_axes(self) -> tuple[float, float, float]:
"""Per-axis resonance angular frequency (rad/s). Zero for pure Drude poles."""
raise NotImplementedError
@property
@abstractmethod
def gamma_axes(self) -> tuple[float, float, float]:
"""Per-axis damping rate (rad/s)."""
raise NotImplementedError
@property
@abstractmethod
def coupling_sq_axes(self) -> tuple[float, float, float]:
"""Per-axis effective squared coupling frequency ``K`` (rad^2/s^2).
``delta_epsilon * omega_0**2`` for a Lorentz pole and
``omega_p**2`` for a Drude pole.
This is the coefficient ``a`` of the ``E`` driving term in the unified
2nd-order ODE ``p'' + gamma p' + omega_0**2 p = a E``.
"""
raise NotImplementedError
@property
def is_oriented(self) -> bool:
"""Whether the pole is a 1D oscillator along an :attr:`orientation` vector."""
return self.orientation is not None
@property
def is_isotropic(self) -> bool:
"""Whether the pole acts identically on the three axes (and is not oriented)."""
return (
self.orientation is None
and _is_uniform(self.omega_0_axes)
and _is_uniform(self.gamma_axes)
and _is_uniform(self.coupling_sq_axes)
)
@property
def omega_0(self) -> float:
"""Resonance angular frequency (rad/s). Zero for pure Drude poles.
Raises ``ValueError`` for per-axis poles; use :attr:`omega_0_axes`.
"""
return self._uniform_or_raise(self.omega_0_axes, "omega_0")
@property
def gamma(self) -> float:
"""Damping rate (rad/s).
Raises ``ValueError`` for per-axis poles; use :attr:`gamma_axes`.
"""
return self._uniform_or_raise(self.gamma_axes, "gamma")
@property
def coupling_sq(self) -> float:
"""Effective squared coupling frequency ``K`` (rad^2/s^2).
Raises ``ValueError`` for per-axis poles; use :attr:`coupling_sq_axes`.
"""
return self._uniform_or_raise(self.coupling_sq_axes, "coupling_sq")
[docs]
@autoinit
class LorentzPole(Pole):
"""Lorentz pole parameterised by its physical constants.
The contribution to the susceptibility is
.. math::
\\chi(\\omega) = \\frac{\\Delta\\varepsilon \\cdot \\omega_0^2}{\\omega_0^2 - \\omega^2 - i\\gamma\\omega}.
Each parameter is either a scalar (isotropic) or a per-axis 3-tuple
``(x, y, z)`` for diagonally anisotropic dispersion. An axis without a
resonance is expressed by a zero ``delta_epsilon`` entry on that axis.
"""
#: Resonance angular frequency (rad/s). Must be > 0.
#: Scalar or per-axis 3-tuple.
resonance_frequency: float | tuple[float, float, float] = frozen_field()
#: Damping rate (rad/s). Must be >= 0. Scalar or per-axis 3-tuple.
damping: float | tuple[float, float, float] = frozen_field()
#: Oscillator strength (dimensionless); the zero-frequency
#: contribution to the susceptibility. Scalar or per-axis 3-tuple.
delta_epsilon: float | tuple[float, float, float] = frozen_field()
def __post_init__(self):
self._validate_orientation()
@property
def omega_0_axes(self) -> tuple[float, float, float]:
w = _broadcast_axis_param(self.resonance_frequency)
return (float(w[0]), float(w[1]), float(w[2]))
@property
def gamma_axes(self) -> tuple[float, float, float]:
g = _broadcast_axis_param(self.damping)
return (float(g[0]), float(g[1]), float(g[2]))
@property
def coupling_sq_axes(self) -> tuple[float, float, float]:
w = self.omega_0_axes
de = _broadcast_axis_param(self.delta_epsilon)
return (float(de[0]) * w[0] ** 2, float(de[1]) * w[1] ** 2, float(de[2]) * w[2] ** 2)
[docs]
@autoinit
class DrudePole(Pole):
"""Drude pole parameterised by its physical constants.
The contribution to the susceptibility is
.. math::
\\chi(\\omega) = -\\frac{\\omega_p^2}{\\omega^2 + i\\gamma\\omega},
equivalent to a Lorentz pole with ``omega_0 = 0``.
Each parameter is either a scalar (isotropic) or a per-axis 3-tuple
``(x, y, z)`` for diagonally anisotropic dispersion — e.g.
``plasma_frequency=(wp, 0.0, 0.0)`` gives a metallic (hyperbolic)
response only along x.
"""
#: Plasma angular frequency (rad/s). Must be > 0.
#: Scalar or per-axis 3-tuple.
plasma_frequency: float | tuple[float, float, float] = frozen_field()
#: Damping rate (rad/s). Must be >= 0. Scalar or per-axis 3-tuple.
damping: float | tuple[float, float, float] = frozen_field()
def __post_init__(self):
self._validate_orientation()
@property
def omega_0_axes(self) -> tuple[float, float, float]:
return (0.0, 0.0, 0.0)
@property
def gamma_axes(self) -> tuple[float, float, float]:
g = _broadcast_axis_param(self.damping)
return (float(g[0]), float(g[1]), float(g[2]))
@property
def coupling_sq_axes(self) -> tuple[float, float, float]:
wp = _broadcast_axis_param(self.plasma_frequency)
return (float(wp[0]) ** 2, float(wp[1]) ** 2, float(wp[2]) ** 2)
[docs]
@autoinit
class DispersionModel(TreeClass):
"""Linear susceptibility built from a sum of 2nd-order ADE poles.
The high-frequency permittivity :math:`\\varepsilon_\\infty` is NOT
stored here - it lives in the parent :class:`~fdtdx.materials.Material`
as the existing ``permittivity`` field. This keeps a single source of
truth for the ``inv_permittivities`` array.
"""
#: Tuple of poles making up the susceptibility model.
poles: tuple[Pole, ...] = frozen_field(default=())
@property
def num_poles(self) -> int:
"""Number of poles in this model."""
return len(self.poles)
@property
def is_isotropic(self) -> bool:
"""Whether every pole applies the same parameters to all three axes."""
return all(p.is_isotropic for p in self.poles)
@property
def has_off_diagonal_coupling(self) -> bool:
"""Whether any pole is oriented (contributing an off-diagonal coupling tensor)."""
return any(p.is_oriented for p in self.poles)
[docs]
def susceptibility_tensor(self, omega: complex | float) -> np.ndarray:
"""Evaluate the full 3x3 complex susceptibility tensor :math:`\\chi_{ij}(\\omega)`.
Oriented poles contribute :math:`\\chi_p(\\omega)\\, u_p u_p^T`;
per-axis and isotropic poles contribute diagonal terms. Uses the
``exp(-i omega t)`` Fourier convention.
Args:
omega: Angular frequency (rad/s).
Returns:
Complex numpy array of shape ``(3, 3)``.
"""
w = complex(omega)
total = np.zeros((3, 3), dtype=np.complex128)
for p in self.poles:
omega_0 = p.omega_0_axes
gamma = p.gamma_axes
coupling_sq = p.coupling_sq_axes
if p.is_oriented:
assert p.orientation is not None
denom = omega_0[0] ** 2 - w * w - 1j * gamma[0] * w
u = np.asarray(p.orientation, dtype=np.float64)
total += (coupling_sq[0] / denom) * np.outer(u, u)
else:
for ax in range(3):
denom = omega_0[ax] ** 2 - w * w - 1j * gamma[ax] * w
total[ax, ax] += coupling_sq[ax] / denom
return total
[docs]
def permittivity_tensor(
self,
omega: complex | float,
eps_inf: float | tuple = 1.0,
) -> np.ndarray:
"""Full 3x3 complex relative permittivity tensor :math:`\\varepsilon_\\infty + \\chi(\\omega)`.
Args:
omega: Angular frequency (rad/s).
eps_inf: High-frequency permittivity — scalar, 3-tuple (diagonal),
flat 9-tuple or nested 3x3. Defaults to 1.0.
Returns:
Complex numpy array of shape ``(3, 3)``.
"""
eps_arr = np.asarray(eps_inf, dtype=np.complex128)
if eps_arr.ndim == 0:
eps_mat = np.eye(3, dtype=np.complex128) * complex(eps_arr)
elif eps_arr.shape == (3,):
eps_mat = np.diag(eps_arr)
elif eps_arr.shape == (9,):
eps_mat = eps_arr.reshape(3, 3)
elif eps_arr.shape == (3, 3):
eps_mat = eps_arr
else:
raise ValueError(f"eps_inf must be a scalar, 3-tuple, flat 9-tuple or 3x3, got shape {eps_arr.shape}.")
return eps_mat + self.susceptibility_tensor(omega)
[docs]
def rotated(self, rotation: tuple) -> "DispersionModel":
"""Return a copy of this model with the crystal axes rotated.
Args:
rotation: Either a 3x3 rotation matrix as a nested tuple
``((r11, r12, r13), ...)`` or a 3-tuple of Euler angles
``(alpha, beta, gamma)`` in radians, applied extrinsically as
``R = Rz(gamma) @ Ry(beta) @ Rx(alpha)``.
Returns:
DispersionModel: Isotropic poles are unchanged; oriented poles have
their direction rotated; per-axis poles are decomposed into up to
three oriented poles (one per axis with non-zero coupling), so the
pole count — and with it the simulation's pole-slot memory — can
grow. For a rotation that is a signed axis permutation (e.g. 90
degree rotations), per-axis poles are instead remapped in place and
keep the cheaper diagonal representation.
"""
r_mat = _as_rotation_matrix(rotation)
perm = _signed_permutation(r_mat)
new_poles: list[Pole] = []
for p in self.poles:
if p.is_oriented:
assert p.orientation is not None
u = r_mat @ np.asarray(p.orientation, dtype=np.float64)
new_poles.append(p.aset("orientation", (float(u[0]), float(u[1]), float(u[2]))))
elif p.is_isotropic:
new_poles.append(p)
elif perm is not None:
new_poles.append(_permute_pole_axes(p, perm))
else:
for ax in range(3):
if p.coupling_sq_axes[ax] == 0.0:
continue
direction = (float(r_mat[0, ax]), float(r_mat[1, ax]), float(r_mat[2, ax]))
new_poles.append(_oriented_pole_for_axis(p, ax, direction))
return DispersionModel(poles=tuple(new_poles))
[docs]
def susceptibility_axes(self, omega: complex | float) -> tuple[complex, complex, complex]:
"""Evaluate the per-axis complex susceptibility :math:`(\\chi_x, \\chi_y, \\chi_z)`.
Uses the ``exp(-i omega t)`` Fourier convention (damping appears
with a ``-i gamma omega`` term in the Lorentz denominator). For an
isotropic model all three entries are equal.
Args:
omega: Angular frequency (rad/s).
Returns:
tuple: :math:`\\chi_a(\\omega) = \\sum_p \\chi_{p,a}(\\omega)` for
each axis ``a`` in ``(x, y, z)``.
"""
if self.has_off_diagonal_coupling:
raise ValueError(
"DispersionModel has oriented poles; use susceptibility_tensor(omega) for the full 3x3 tensor."
)
w = complex(omega)
totals = [0.0 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j]
for p in self.poles:
omega_0 = p.omega_0_axes
gamma = p.gamma_axes
coupling_sq = p.coupling_sq_axes
for ax in range(3):
denom = omega_0[ax] ** 2 - w * w - 1j * gamma[ax] * w
totals[ax] = totals[ax] + coupling_sq[ax] / denom
return (totals[0], totals[1], totals[2])
[docs]
def susceptibility(self, omega: complex | float) -> complex:
"""Evaluate the complex susceptibility :math:`\\chi(\\omega)`.
Uses the ``exp(-i omega t)`` Fourier convention (damping appears
with a ``-i gamma omega`` term in the Lorentz denominator).
Raises ``ValueError`` for models with per-axis poles; use
:meth:`susceptibility_axes` for those.
Args:
omega: Angular frequency (rad/s).
Returns:
complex: :math:`\\chi(\\omega) = \\sum_p \\chi_p(\\omega)`.
"""
if not self.is_isotropic:
raise ValueError(
"DispersionModel has per-axis poles; use susceptibility_axes(omega) for the (x, y, z) values."
)
return self.susceptibility_axes(omega)[0]
[docs]
def permittivity_axes(
self,
omega: complex | float,
eps_inf: float | tuple[float, float, float] = 1.0,
) -> tuple[complex, complex, complex]:
"""Per-axis complex relative permittivity :math:`\\varepsilon_a(\\omega) = \\varepsilon_{\\infty,a} + \\chi_a(\\omega)`.
Args:
omega: Angular frequency (rad/s).
eps_inf: High-frequency permittivity — scalar or per-axis
3-tuple (the diagonal of the ε∞ tensor). Defaults to 1.0.
Returns:
tuple: Relative permittivity at ``omega`` per axis ``(x, y, z)``.
"""
chi = self.susceptibility_axes(omega)
e = _broadcast_axis_param(eps_inf)
return (complex(e[0]) + chi[0], complex(e[1]) + chi[1], complex(e[2]) + chi[2])
[docs]
def permittivity(self, omega: complex | float, eps_inf: float = 1.0) -> complex:
"""Complex relative permittivity :math:`\\varepsilon(\\omega) = \\varepsilon_\\infty + \\chi(\\omega)`.
Raises ``ValueError`` for models with per-axis poles; use
:meth:`permittivity_axes` for those.
Args:
omega: Angular frequency (rad/s).
eps_inf: High-frequency permittivity. Defaults to 1.0 (vacuum).
Returns:
complex: Relative permittivity at ``omega``.
"""
return eps_inf + self.susceptibility(omega)
[docs]
def compute_pole_coefficients_per_axis(
poles: tuple[Pole, ...],
dt: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Compute the per-axis discrete-time ADE recurrence coefficients.
For each pole and grid axis, returns ``(c1, c2, c3)`` with
(``D = 1 + gamma dt / 2``)
.. math::
c_1 = \\frac{2 - \\omega_0^2 \\Delta t^2}{D}, \\quad
c_2 = -\\frac{1 - \\gamma \\Delta t / 2}{D}, \\quad
c_3 = \\frac{K \\Delta t^2}{D},
where ``K = coupling_sq`` is the ``E`` coupling of the unified ODE:
:math:`p_p^{n+1} = c_1 p_p^n + c_2 p_p^{n-1} + c_3 E^n`.
For isotropic poles the three axis columns are identical.
Args:
poles: Tuple of poles (may be empty).
dt: Simulation time step (seconds).
Returns:
Three ``numpy`` arrays of shape ``(len(poles), 3)`` with ``c1``, ``c2``,
``c3`` per pole and axis. For an empty pole tuple, returns three
``(0, 3)`` arrays.
"""
n = len(poles)
c1 = np.zeros((n, 3), dtype=np.float64)
c2 = np.zeros((n, 3), dtype=np.float64)
c3 = np.zeros((n, 3), dtype=np.float64)
for i, p in enumerate(poles):
if p.is_oriented:
raise ValueError(
f"Pole {i} ({type(p).__name__}) is oriented; use compute_pole_coefficients_tensor instead."
)
omega_0 = p.omega_0_axes
gamma = p.gamma_axes
coupling_sq = p.coupling_sq_axes
for ax in range(3):
gamma_dt = gamma[ax] * dt
omega0_dt = omega_0[ax] * dt
# The stability bound only binds on axes where the pole actually
# couples. A zero-coupling axis (e.g. a Lorentz pole with
# delta_epsilon = 0 there, the documented way to express an absent
# resonance) has c3 = 0, so its polarization stays identically
# zero and its unused omega_0 / gamma are irrelevant.
axis_active = coupling_sq[ax] != 0.0
if axis_active and omega0_dt >= 2.0:
axis_note = "" if p.is_isotropic else f" on axis {'xyz'[ax]}"
raise ValueError(
f"Pole {i} ({type(p).__name__}) has omega_0 * dt = {omega0_dt:.4g} >= 2{axis_note}; "
"the ADE recurrence roots leave the unit circle (requires omega_0 * dt < 2, "
"physically omega_0 * dt << 1). Lower the resonance frequency or reduce the time step."
)
denom = 1.0 + 0.5 * gamma_dt
c1[i, ax] = (2.0 - (omega_0[ax] ** 2) * (dt**2)) / denom
c2[i, ax] = -(1.0 - 0.5 * gamma_dt) / denom
c3[i, ax] = (coupling_sq[ax] * dt**2) / denom
return c1, c2, c3
[docs]
def compute_pole_coefficients(
poles: tuple[Pole, ...],
dt: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Compute the discrete-time ADE recurrence coefficients of isotropic poles.
Scalar-per-pole variant of :func:`compute_pole_coefficients_per_axis` (see
there for the coefficient definitions). Raises ``ValueError`` when any
pole has per-axis parameters — use the per-axis function for those.
Args:
poles: Tuple of isotropic poles (may be empty).
dt: Simulation time step (seconds).
Returns:
Three ``numpy`` arrays of shape ``(len(poles),)`` with ``c1``, ``c2``,
``c3``. For an empty pole tuple, returns three empty arrays.
"""
for i, p in enumerate(poles):
if not p.is_isotropic:
raise ValueError(
f"Pole {i} ({type(p).__name__}) has per-axis parameters or an orientation; "
"use compute_pole_coefficients_per_axis or compute_pole_coefficients_tensor instead."
)
c1, c2, c3 = compute_pole_coefficients_per_axis(poles, dt)
return c1[:, 0], c2[:, 0], c3[:, 0]
[docs]
def compute_pole_coefficients_tensor(
poles: tuple[Pole, ...],
dt: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Compute ADE recurrence coefficients with full 3x3 coupling tensors.
Generalizes :func:`compute_pole_coefficients_per_axis` to oriented poles:
the recurrence coefficients ``c1``/``c2`` stay per-axis (uniform for an
oriented pole, whose ``omega_0``/``gamma`` are scalars), while the field
coupling ``c3`` becomes a row-major 3x3 tensor per pole —
``(K dt^2 / D) u u^T`` for a pole oriented along ``u``, diagonal for
per-axis and isotropic poles.
Args:
poles: Tuple of poles (may be empty). Oriented poles must have a
non-negative coupling ``K`` (passivity of the ``K u u^T`` tensor).
dt: Simulation time step (seconds).
Returns:
Three ``numpy`` arrays: ``c1``, ``c2`` of shape ``(len(poles), 3)`` and
``c3`` of shape ``(len(poles), 9)``.
"""
n = len(poles)
c1 = np.zeros((n, 3), dtype=np.float64)
c2 = np.zeros((n, 3), dtype=np.float64)
c3 = np.zeros((n, 9), dtype=np.float64)
for i, p in enumerate(poles):
gamma = p.gamma_axes
omega_0 = p.omega_0_axes
coupling_sq = p.coupling_sq_axes
for ax in range(3):
gamma_dt = gamma[ax] * dt
omega0_dt = omega_0[ax] * dt
# Same forward-stability bound (and same zero-coupling exemption) as
# compute_pole_coefficients_per_axis.
axis_active = coupling_sq[ax] != 0.0
if axis_active and omega0_dt >= 2.0:
raise ValueError(
f"Pole {i} ({type(p).__name__}) has omega_0 * dt = {omega0_dt:.4g} >= 2; "
"the ADE recurrence roots leave the unit circle (requires omega_0 * dt < 2, "
"physically omega_0 * dt << 1). Lower the resonance frequency or reduce the time step."
)
denom = 1.0 + 0.5 * gamma_dt
c1[i, ax] = (2.0 - (omega_0[ax] ** 2) * (dt**2)) / denom
c2[i, ax] = -(1.0 - 0.5 * gamma_dt) / denom
if p.is_oriented:
if coupling_sq[0] < 0.0:
raise ValueError(
f"Pole {i} ({type(p).__name__}) has negative coupling K = {coupling_sq[0]:.4g}; "
"oriented poles require K >= 0 so the coupling tensor K u u^T stays positive "
"semi-definite (passivity)."
)
assert p.orientation is not None
u = np.asarray(p.orientation, dtype=np.float64)
denom = 1.0 + 0.5 * gamma[0] * dt
c3[i] = ((coupling_sq[0] * dt**2 / denom) * np.outer(u, u)).reshape(-1)
else:
for ax in range(3):
denom = 1.0 + 0.5 * gamma[ax] * dt
c3[i, 4 * ax] = (coupling_sq[ax] * dt**2) / denom
return c1, c2, c3
def _tensor_from_components(arr: jax.Array) -> jax.Array:
"""Expand a component array ``(1|3|9, *spatial)`` to a matrix field ``(3, 3, *spatial)``."""
if arr.shape[0] == 9:
return arr.reshape(3, 3, *arr.shape[1:])
diag = jnp.broadcast_to(arr, (3, *arr.shape[1:]))
return jnp.zeros((3, 3, *arr.shape[1:]), dtype=arr.dtype).at[jnp.arange(3), jnp.arange(3)].set(diag)
def _invert_3x3_matrix_field(mat: jax.Array) -> jax.Array:
"""Per-cell inverse of a matrix field ``(3, 3, *spatial)``."""
moved = jnp.moveaxis(mat, (0, 1), (-2, -1))
return jnp.moveaxis(jnp.linalg.inv(moved), (-2, -1), (0, 1))
def _eps_matrix_from_inv(inv_eps: jax.Array) -> jax.Array:
"""Per-cell permittivity matrix ``(3, 3, *spatial)`` from stored inverse components."""
if inv_eps.shape[0] == 9:
return _invert_3x3_matrix_field(_tensor_from_components(inv_eps))
return _tensor_from_components(1.0 / inv_eps)
def _expand_recurrence_to_coupling(c: jax.Array, coupling_components: int) -> jax.Array:
"""Expand a recurrence coefficient's component axis to match a 9-component coupling axis.
A per-axis coefficient ``(P, 3, ...)`` becomes ``(P, 9, ...)`` where the
row-major coupling entry ``3i+j`` uses the oscillator of row ``i``. Size-1
axes broadcast as-is.
"""
if coupling_components != 9 or c.shape[1] != 3:
return c
return jnp.repeat(c, 3, axis=1)
def susceptibility_from_coefficients(
c1: jax.Array,
c2: jax.Array,
c3: jax.Array,
omega: float,
dt: float,
) -> jax.Array:
"""Evaluate the per-cell complex susceptibility :math:`\\chi(\\omega)` from
the stored ADE recurrence coefficients.
The coefficient arrays have shape ``(num_poles, ...)`` where the trailing
axes are the spatial (and optional component) dimensions. The inversion
(with ``D = 1 + \\gamma \\Delta t / 2``)
.. math::
\\gamma \\Delta t &= \\frac{2 (1 + c_2)}{1 - c_2},\\\\
\\omega_0^2 \\Delta t^2 &= 2 - c_1 D,\\\\
K \\Delta t^2 &= c_3 D
is applied pointwise, then each pole contributes
.. math::
\\chi_p(\\omega) = \\frac{K}{\\omega_0^2 - \\omega^2 - i \\gamma \\omega}
and the result is summed over the leading pole axis. Cells where the
coefficients are all zero (no pole) contribute exactly zero.
Args:
c1: ADE coefficient array of shape ``(num_poles, ...)``.
c2: ADE coefficient array of shape ``(num_poles, ...)``.
c3: ADE coefficient array of shape ``(num_poles, ...)``.
omega: Angular frequency (rad/s) at which to evaluate the
susceptibility.
dt: Simulation time step (seconds) used to derive the coefficients.
Returns:
Complex ``jax.Array`` with shape ``c1.shape[1:]`` — the total
:math:`\\chi(\\omega)` summed over all poles, in every cell.
"""
c1 = jnp.asarray(c1)
c2 = jnp.asarray(c2)
c3 = jnp.asarray(c3)
if c1.ndim >= 2 and c3.ndim >= 2 and c3.shape[1] == 9:
# 9-component coupling (oriented poles): the recurrence coefficients
# expand so entry (i, j) uses the oscillator of row i; the result is
# the per-entry chi_ij with shape (9, *spatial).
c1 = _expand_recurrence_to_coupling(c1, 9)
c2 = _expand_recurrence_to_coupling(c2, 9)
pole_mask = (c1 != 0.0) | (c3 != 0.0)
one_minus_c2 = 1.0 - c2
safe_denom = jnp.where(one_minus_c2 == 0.0, 1.0, one_minus_c2)
gamma_dt = 2.0 * (1.0 + c2) / safe_denom
gamma_dt = jnp.where(pole_mask, gamma_dt, 0.0)
half_factor = 1.0 + 0.5 * gamma_dt
omega0_sq_dt2 = 2.0 - c1 * half_factor
omega0_sq_dt2 = jnp.where(pole_mask, omega0_sq_dt2, 0.0)
# K*dt^2 = c3*D (see compute_pole_coefficients).
k_dt2 = jnp.where(pole_mask, c3 * half_factor, 0.0)
omega_dt = omega * dt
denom = omega0_sq_dt2 - omega_dt * omega_dt - 1j * gamma_dt * omega_dt
safe_denom_cplx = jnp.where(pole_mask, denom, 1.0 + 0.0j)
chi_per_pole = jnp.where(pole_mask, k_dt2 / safe_denom_cplx, 0.0 + 0.0j)
return jnp.sum(chi_per_pole, axis=0)
[docs]
def compute_eps_spectrum_from_coefficients(
c1: jax.Array | np.ndarray,
c2: jax.Array | np.ndarray,
c3: jax.Array | np.ndarray,
inv_eps_inf: jax.Array | np.ndarray,
omegas: np.ndarray,
dt: float,
weights: np.ndarray | None = None,
) -> np.ndarray:
"""Spatially-averaged complex permittivity spectrum for a block of cells.
For each angular frequency in ``omegas``, evaluates the per-cell
complex permittivity :math:`\\varepsilon(\\omega) = \\varepsilon_\\infty + \\chi(\\omega)`
where :math:`\\chi` is reconstructed from the ADE recurrence coefficients,
and averages over the spatial axes (uniformly or with supplied weights).
This is the broadband generalization of the single-frequency
:func:`effective_inv_permittivity` used for carrier-frequency impedance
matching — callers that need a frequency-dependent impedance (e.g. for
a convolution-based broadband source correction) use this to build the
:math:`\\varepsilon(\\omega)` spectrum that feeds
:func:`compute_impedance_corrected_temporal_profile`.
Args:
c1: ADE coefficient array of shape ``(num_poles, num_components, *spatial)``
as stored on :class:`~fdtdx.fdtd.container.ArrayContainer`, with
``num_components in (1, 3)`` (the material-component axis; size 3
for per-axis anisotropic dispersion). Anisotropic components are
averaged, mirroring the ``inv_eps_inf`` reduction.
c2: ADE coefficient array, same shape as ``c1``.
c3: ADE coefficient array, same shape as ``c1``.
inv_eps_inf: Per-cell inverse of the high-frequency permittivity,
shape ``(num_components, *spatial)`` with
``num_components in (1, 3, 9)``. For anisotropic tensors
(9 components) only the diagonal entries are used.
omegas: 1D array of angular frequencies (rad/s) to evaluate at.
dt: Simulation time step (seconds) used to derive the coefficients.
weights: Optional spatial weights with the same shape as the
trailing axes of ``c1``. If ``None``, uniform averaging.
Returns:
Complex numpy array of shape ``(len(omegas),)`` — the volume-averaged
:math:`\\varepsilon(\\omega)` at each requested frequency.
"""
c1_np = np.asarray(c1)
c2_np = np.asarray(c2)
c3_np = np.asarray(c3)
inv_eps_np = np.asarray(inv_eps_inf)
omegas_np = np.asarray(omegas, dtype=np.float64)
if c3_np.ndim >= 2 and c3_np.shape[1] == 9 and c1_np.shape[1] == 3:
# 9-component coupling (oriented poles): entry 3i+j uses oscillator row i.
c1_np = np.repeat(c1_np, 3, axis=1)
c2_np = np.repeat(c2_np, 3, axis=1)
# Reverse-engineer pole parameters from the ADE coefficients (same inversion
# as susceptibility_from_coefficients, duplicated in numpy for setup-time use).
pole_mask = (c1_np != 0.0) | (c3_np != 0.0)
one_minus_c2 = 1.0 - c2_np
safe_one_minus_c2 = np.where(one_minus_c2 == 0.0, 1.0, one_minus_c2)
gamma_dt = np.where(pole_mask, 2.0 * (1.0 + c2_np) / safe_one_minus_c2, 0.0)
half_factor = 1.0 + 0.5 * gamma_dt
omega0_sq_dt2 = np.where(pole_mask, 2.0 - c1_np * half_factor, 0.0)
# K*dt^2 = c3*D (numerator of the Lorentzian).
k_dt2 = np.where(pole_mask, c3_np * half_factor, 0.0)
# Reduce inv_eps_inf → scalar eps_inf per spatial cell.
num_components = inv_eps_np.shape[0]
if num_components == 9:
# inv_eps_inf stores the inverse tensor and diag(eps) != 1/diag(eps^-1)
# when off-diagonal terms exist: invert each cell's 3x3 before averaging.
spatial = inv_eps_np.shape[1:]
inv_mats = np.moveaxis(inv_eps_np.reshape(3, 3, -1), -1, 0)
eps_diag_mean = np.trace(np.linalg.inv(inv_mats), axis1=-2, axis2=-1) / 3.0
eps_inf_per_cell = eps_diag_mean.reshape(spatial)
elif num_components in (1, 3):
eps_inf_per_cell = np.mean(1.0 / inv_eps_np, axis=0)
else:
raise ValueError(f"Unexpected inv_eps_inf leading dimension {num_components}; expected 1, 3, or 9.")
# Broadcast: omegas over (M,); coefficient arrays have shape (P, C, *spatial)
# with C in (1, 3). After [None, ...] prepend: (M, P, C, *spatial).
omega_dt = (omegas_np * dt).reshape((-1,) + (1,) * c1_np.ndim)
denom = omega0_sq_dt2[None, ...] - omega_dt**2 - 1j * gamma_dt[None, ...] * omega_dt
safe_denom = np.where(pole_mask[None, ...], denom, 1.0 + 0.0j)
chi_per_pole = np.where(pole_mask[None, ...], k_dt2[None, ...] / safe_denom, 0.0 + 0.0j)
chi_per_cell = chi_per_pole.sum(axis=1) # sum over pole axis → (M, C, *spatial)
# Average the material-component axis (identity for C = 1), mirroring the
# eps_inf reduction above — this scalar spectrum feeds an impedance filter
# that has no notion of polarization. For a 9-component coupling only the
# diagonal entries carry impedance information.
if chi_per_cell.shape[1] == 9:
chi_per_cell = chi_per_cell[:, (0, 4, 8)].mean(axis=1)
else:
chi_per_cell = chi_per_cell.mean(axis=1) # → (M, *spatial)
eps_per_cell = eps_inf_per_cell[None, ...] + chi_per_cell # (M, *spatial)
if weights is None:
flat = eps_per_cell.reshape(eps_per_cell.shape[0], -1)
return flat.mean(axis=1)
weights_np = np.asarray(weights, dtype=np.float64).reshape(-1)
flat = eps_per_cell.reshape(eps_per_cell.shape[0], -1)
weight_sum = weights_np.sum()
if weight_sum == 0.0:
return flat.mean(axis=1)
return (flat * weights_np).sum(axis=1) / weight_sum
[docs]
def compute_impedance_corrected_temporal_profile(
raw_samples: np.ndarray,
dt: float,
eps_spectrum: np.ndarray,
eps_center: complex,
) -> np.ndarray:
"""FIR-filter a raw source temporal profile for broadband impedance matching.
Given the unfiltered E-side temporal profile ``s(n·dt)`` and the complex
permittivity spectrum ``eps_spectrum = ε(ω_k)`` at the rFFT frequencies
of a zero-padded version of ``s``, returns the H-side temporal profile
``s_H(n·dt)`` whose spectrum satisfies
:math:`\\tilde{s}_H(\\omega) = \\tilde{s}(\\omega) \\cdot G(\\omega)` with
.. math::
G(\\omega) = \\frac{\\eta(\\omega_c)}{\\eta(\\omega)}
= \\sqrt{\\frac{\\varepsilon(\\omega)}{\\varepsilon(\\omega_c)}}
(assuming a non-dispersive permeability). Injecting the prescribed E and
H fields as ``E(x,t) = E_spatial(x)·s(t)`` and
``H(x,t) = (H_spatial(x)/η(ω_c))·s_H(t)`` then reproduces a physical
plane wave at every frequency in the pulse bandwidth, not just at
``ω_c``. In the non-dispersive limit ``ε(ω) ≡ ε_c`` and ``G`` is the
identity so ``s_H == s``.
Implementation: zero-pads to ``M = 2·(len(eps_spectrum) - 1)`` for
linear convolution, takes a real FFT, multiplies by ``G``, and transforms
back with :func:`numpy.fft.irfft` (which enforces a real output via
Hermitian symmetry of the positive-frequency spectrum).
Args:
raw_samples: Real 1-D array of the unfiltered temporal profile
sampled at integer time steps, ``s[n] = s(n·dt)``.
dt: Simulation time step (seconds). Present for API symmetry; the
actual time step is encoded in ``eps_spectrum``.
eps_spectrum: Complex 1-D array of length ``M/2 + 1`` giving
:math:`\\varepsilon(\\omega)` at
:math:`\\omega_k = 2\\pi \\cdot k / (M \\cdot \\Delta t)` for
``k = 0, ..., M/2``.
eps_center: Scalar complex :math:`\\varepsilon(\\omega_c)` at the
source carrier frequency.
Returns:
Real 1-D array of length ``len(raw_samples)`` containing ``s_H[n]``.
"""
del dt
raw = np.asarray(raw_samples, dtype=np.float64)
n = raw.shape[0]
m = (eps_spectrum.shape[0] - 1) * 2
if m < n:
raise ValueError(
f"eps_spectrum of length {eps_spectrum.shape[0]} corresponds to "
f"M={m} FFT points, which is smaller than the raw profile length {n}."
)
padded = np.zeros(m, dtype=np.float64)
padded[:n] = raw
spectrum = np.fft.rfft(padded)
ratio = np.asarray(eps_spectrum, dtype=np.complex128) / complex(eps_center)
filter_response = np.sqrt(ratio)
# DC bin: eps(0) can be ill-defined for Drude poles (1/0 in the physical
# continuum). A real s(t) has a real S(0) anyway, and a real-valued
# correction there is enough — use G(0)=1 so the filter is the identity
# at DC. The Nyquist bin must also be real for irfft to produce a real
# output; take the real part to be safe.
filter_response[0] = 1.0 + 0.0j
filter_response[-1] = complex(np.real(filter_response[-1]), 0.0)
filtered_spectrum = spectrum * filter_response
filtered = np.fft.irfft(filtered_spectrum, n=m)
return filtered[:n].astype(np.float64)
def effective_inv_permittivity(
inv_eps: jax.Array,
c1: jax.Array | None,
c2: jax.Array | None,
c3: jax.Array | None,
omega: float,
dt: float,
) -> jax.Array:
"""Per-cell real inverse permittivity :math:`1/\\text{Re}(\\varepsilon_\\infty + \\chi(\\omega))`.
Sources in FDTDX use a real wave impedance, so only the real part of
``ε∞ + χ(ω)`` enters the injected amplitude. The imaginary part describes
absorption, which is already handled by the ADE update loop (injecting it
into the source amplitude would double-count).
Cells with no pole (``c1 = c2 = c3 = 0``) contribute :math:`\\chi = 0` so
their ``inv_eps`` is returned unchanged.
Args:
inv_eps: Per-cell :math:`1/\\varepsilon_\\infty` array. Typically
has shape ``(num_components, ...)``; any shape broadcast-compatible
with ``c1.shape[1:]`` works.
c1: ADE coefficient array of shape ``(num_poles, ...)`` or ``None``.
c2: ADE coefficient array of shape ``(num_poles, ...)`` or ``None``.
c3: ADE coefficient array of shape ``(num_poles, ...)`` or ``None``.
omega: Angular frequency (rad/s) at which to evaluate.
dt: Simulation time step (seconds).
Returns:
Real-valued ``jax.Array`` with the same shape and dtype as
``inv_eps``. If any of ``c1``/``c2``/``c3`` is ``None``, returns
``inv_eps`` unchanged.
"""
inv_eps_arr = jnp.asarray(inv_eps)
coupling_c = jnp.asarray(c3).shape[1] if c3 is not None and jnp.asarray(c3).ndim >= 2 else 1
if inv_eps_arr.shape[0] == 9 or coupling_c == 9:
# Tensor path: reconstruct the real permittivity matrix per cell, add
# the (possibly off-diagonal) susceptibility, and invert per cell.
# Elementwise 1/inv_eps would divide by the zero off-diagonal entries.
eps_mat = jnp.real(_eps_matrix_from_inv(inv_eps_arr))
if c1 is not None and c2 is not None and c3 is not None:
chi = susceptibility_from_coefficients(c1=c1, c2=c2, c3=c3, omega=omega, dt=dt)
eps_mat = eps_mat + jnp.real(_tensor_from_components(chi))
inv_eff = _invert_3x3_matrix_field(eps_mat)
return inv_eff.reshape(9, *inv_eff.shape[2:]).astype(inv_eps_arr.dtype)
if c1 is None or c2 is None or c3 is None:
return inv_eps
chi = susceptibility_from_coefficients(c1=c1, c2=c2, c3=c3, omega=omega, dt=dt)
eps_inf = 1.0 / inv_eps_arr
eps_eff = eps_inf + jnp.real(chi)
return (1.0 / eps_eff).astype(inv_eps_arr.dtype)
def effective_complex_inv_permittivity(
inv_eps: jax.Array,
omega: float,
dt: float,
c1: jax.Array | None = None,
c2: jax.Array | None = None,
c3: jax.Array | None = None,
electric_conductivity: jax.Array | None = None,
conductivity_spacing: float | None = None,
) -> jax.Array:
r"""Per-cell COMPLEX inverse permittivity :math:`1 / (\varepsilon_\infty + \chi(\omega) + i\sigma/(\varepsilon_0\omega))`.
Unlike :func:`effective_inv_permittivity` — which returns the real
``1/Re(eps)`` for source impedance / energy normalization and deliberately
drops the imaginary part — this keeps the *full complex* permittivity so the
mode solver sees the material loss, yielding a complex effective index and a
lossy mode profile. Use it ONLY for the permittivity handed to the mode
solver, never for impedance / energy (which would double-count the
absorption already integrated by the ADE loop and the conductivity update).
Both loss contributions are added in the ``exp(-i omega t)`` convention
(positive imaginary part = loss):
* the dispersive susceptibility :math:`\chi(\omega)` reconstructed from the
ADE coefficients (omitted when ``c1``/``c2``/``c3`` are ``None``), and
* the conductivity loss :math:`i\,\sigma_\text{phys} / (\varepsilon_0 \omega)`,
where :math:`\sigma_\text{phys} = \sigma_\text{array} / \Delta` recovers the
physical S/m value from the resolution-scaled ``electric_conductivity``
array (``conductivity_spacing`` is the scaling factor
:math:`\Delta = c_0 \Delta t / S` applied at initialization).
Args:
inv_eps: Per-cell ``1/eps_inf`` (real). Shape ``(num_components, ...)``.
omega: Angular frequency (rad/s).
dt: Simulation time step (seconds).
c1: ADE coefficient array of shape ``(num_poles, ...)`` or ``None``.
c2: ADE coefficient array of shape ``(num_poles, ...)`` or ``None``.
c3: ADE coefficient array of shape ``(num_poles, ...)`` or ``None``.
electric_conductivity: Resolution-scaled conductivity array, or ``None``.
conductivity_spacing: Scaling factor used to recover the physical
conductivity. Required when ``electric_conductivity`` is given.
Returns:
Complex ``jax.Array`` broadcasting ``inv_eps`` against the loss terms.
"""
inv_eps = jnp.asarray(inv_eps)
complex_dtype = jnp.complex128 if inv_eps.dtype == jnp.float64 else jnp.complex64
coupling_c = jnp.asarray(c3).shape[1] if c3 is not None and jnp.asarray(c3).ndim >= 2 else 1
if inv_eps.shape[0] == 9 or coupling_c == 9:
# Diagonal reduction for the mode solver: off-diagonal permittivity and
# susceptibility entries are dropped, so modal geometry in monoclinic /
# rotated media is a diagonal approximation.
eps_mat = _eps_matrix_from_inv(inv_eps)
eps = jnp.stack([eps_mat[0, 0], eps_mat[1, 1], eps_mat[2, 2]], axis=0).astype(complex_dtype)
if c1 is not None and c2 is not None and c3 is not None:
chi = susceptibility_from_coefficients(c1=c1, c2=c2, c3=c3, omega=omega, dt=dt)
if chi.shape[0] == 9:
chi = jnp.stack([chi[0], chi[4], chi[8]], axis=0)
eps = eps + chi
if electric_conductivity is not None:
if conductivity_spacing is None:
raise ValueError("conductivity_spacing is required when electric_conductivity is given.")
sigma = jnp.asarray(electric_conductivity)
if sigma.shape[0] == 9:
sigma = jnp.stack([sigma[0], sigma[4], sigma[8]], axis=0)
eps = eps + 1j * (sigma / conductivity_spacing) / (omega * eps0)
return 1.0 / eps
eps = (1.0 / inv_eps).astype(complex_dtype)
if c1 is not None and c2 is not None and c3 is not None:
eps = eps + susceptibility_from_coefficients(c1=c1, c2=c2, c3=c3, omega=omega, dt=dt)
if electric_conductivity is not None:
if conductivity_spacing is None:
raise ValueError("conductivity_spacing is required when electric_conductivity is given.")
sigma_phys = jnp.asarray(electric_conductivity) / conductivity_spacing
eps = eps + 1j * sigma_phys / (omega * eps0)
return 1.0 / eps