"""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}
Two *independent* conditions constrain the coefficients; both are 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):
* **Forward unit-circle (Jury) stability.** 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 forward-stability bound.
* **Reverse-update conditioning.** The backward (reverse-time) recurrence is
formed by dividing through by :math:`c_2`, so :math:`c_2` must stay bounded
away from zero. This is a *separate* requirement, :math:`\\gamma \\Delta t < 2`
(:math:`c_2 = 0` exactly at :math:`\\gamma \\Delta t = 2`); in the physical
regime :math:`\\gamma \\Delta t \\ll 1` so :math:`c_2 \\approx -1` and the
inversion is well conditioned. It is a conditioning bound for reversibility,
not a forward unit-circle criterion.
For CCPR poles the polarization couples to :math:`E^{n+1}` through
:math:`c_4`, so the E-field update divides by a per-cell implicit factor
:math:`1 + \\varepsilon_\\infty^{-1} \\sum_p c_{4,p}\\ (+\\ c\\,\\sigma\\,\\eta_0\\,\\varepsilon_\\infty^{-1} / 2)`.
This must stay positive in every cell; as it approaches :math:`0^+` the
transient gain (:math:`\\approx 1/\\text{divisor}`) explodes and accuracy
collapses. Since :math:`c_4 \\propto \\Delta t \\propto` ``courant_factor``,
the divisor can be kept safe by lowering ``courant_factor``. This per-cell
condition is checked at initialization by
:func:`fdtdx.materials.validate_dispersive_divisor_stability` (Lorentz and
Drude poles have :math:`c_4 = 0`, so their divisor is always
:math:`\\geq 1`).
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.
Off-diagonal (rotated) dispersive tensors are not supported.
"""
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 | complex | 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])
[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.
"""
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 + b E'``.
"""
raise NotImplementedError
@property
def coupling_edot_axes(self) -> tuple[float, float, float]:
"""Per-axis coefficient ``b`` of the ``dE/dt`` driving term (rad/s).
Zero for Lorentz and Drude poles (their susceptibility numerator has no
``omega`` term). A non-zero value is what distinguishes a general
complex-conjugate pole-residue (CCPR) pole — it corresponds to a
non-zero real part of the residue and adds the ``b E'`` term to the ADE.
Defaults to all-zero so existing pole types need not override it.
"""
return (0.0, 0.0, 0.0)
@property
def is_isotropic(self) -> bool:
"""Whether all pole parameters are identical on the three axes."""
return (
_is_uniform(self.omega_0_axes)
and _is_uniform(self.gamma_axes)
and _is_uniform(self.coupling_sq_axes)
and _is_uniform(self.coupling_edot_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")
@property
def coupling_edot(self) -> float:
"""Coefficient ``b`` of the ``dE/dt`` driving term (rad/s).
Raises ``ValueError`` for per-axis poles; use :attr:`coupling_edot_axes`.
"""
return self._uniform_or_raise(self.coupling_edot_axes, "coupling_edot")
[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()
@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()
@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 CCPRPole(Pole):
r"""General complex-conjugate pole-residue (CCPR) pole.
A single conjugate pair contributes to the susceptibility (in the
``exp(-i omega t)`` convention, Laplace variable ``s = -i omega``):
.. math::
\chi_p(\omega) = \frac{r}{-i\omega - q} + \frac{r^*}{-i\omega - q^*}
with **complex** pole ``q`` and **complex** residue ``r``. Summing the pair
with its conjugate guarantees a real time-domain response. Combined over a
common denominator this equals the unified 2nd-order form
.. math::
\chi_p(\omega) = \frac{a - i\omega b}{\omega_0^2 - \omega^2 - i\gamma\omega}
with
.. math::
\omega_0^2 = |q|^2, \quad \gamma = -2\,\mathrm{Re}(q), \quad
a = -2\,\mathrm{Re}(r q^*), \quad b = 2\,\mathrm{Re}(r).
Lorentz and Drude poles are the special case ``b = 0`` (purely imaginary
residue). A non-zero ``b`` (``= coupling_edot``) is the extra degree of
freedom that lets CCPR fit metals (gold, silver) and arbitrary
vector-fitted permittivity data.
A stable, passive (lossy) medium requires ``Re(q) < 0`` (so ``gamma > 0``).
Both ``pole`` and ``residue`` are either scalars (isotropic) or per-axis
3-tuples ``(x, y, z)`` for diagonally anisotropic dispersion (e.g. a
vector-fitted uniaxial material with a different ``(q, r)`` set per axis).
"""
#: Complex pole ``q`` (rad/s). ``Re(q) < 0`` for a stable, lossy medium.
#: Scalar or per-axis 3-tuple.
pole: complex | tuple[complex, complex, complex] = frozen_field()
#: Complex residue ``r`` (rad/s). Scalar or per-axis 3-tuple.
residue: complex | tuple[complex, complex, complex] = frozen_field()
@property
def omega_0_axes(self) -> tuple[float, float, float]:
q = _broadcast_axis_param(self.pole)
return (float(abs(complex(q[0]))), float(abs(complex(q[1]))), float(abs(complex(q[2]))))
@property
def gamma_axes(self) -> tuple[float, float, float]:
q = _broadcast_axis_param(self.pole)
return (
float(-2.0 * complex(q[0]).real),
float(-2.0 * complex(q[1]).real),
float(-2.0 * complex(q[2]).real),
)
@property
def coupling_sq_axes(self) -> tuple[float, float, float]:
q = _broadcast_axis_param(self.pole)
r = _broadcast_axis_param(self.residue)
return (
float(-2.0 * (complex(r[0]) * complex(q[0]).conjugate()).real),
float(-2.0 * (complex(r[1]) * complex(q[1]).conjugate()).real),
float(-2.0 * (complex(r[2]) * complex(q[2]).conjugate()).real),
)
@property
def coupling_edot_axes(self) -> tuple[float, float, float]:
r = _broadcast_axis_param(self.residue)
return (
float(2.0 * complex(r[0]).real),
float(2.0 * complex(r[1]).real),
float(2.0 * complex(r[2]).real),
)
[docs]
@classmethod
def from_critical_point(
cls,
amplitude: float,
phase: float,
resonance_frequency: float,
damping: float,
) -> "CCPRPole":
r"""Build a CCPR pole from critical-point (modified-Lorentz) parameters.
The critical-point model term (``exp(-i omega t)`` convention) is
.. math::
\chi_p(\omega) = A\,\Omega\left[
\frac{e^{i\phi}}{\Omega - \omega - i\Gamma}
+ \frac{e^{-i\phi}}{\Omega + \omega + i\Gamma}\right],
which is the parameterization commonly reported for fitted metal
permittivities. This maps to the complex pole/residue
.. math::
q = -\Gamma - i\Omega, \qquad r = i\,A\,\Omega\,e^{i\phi}.
Args:
amplitude: Dimensionless amplitude :math:`A`.
phase: Phase :math:`\phi` (radians).
resonance_frequency: Resonance :math:`\Omega` (rad/s).
damping: Broadening :math:`\Gamma` (rad/s), ``> 0`` for loss.
Returns:
CCPRPole: Equivalent pole with the ``(q, r)`` above.
"""
import cmath
q = complex(-damping, -resonance_frequency)
r = 1j * amplitude * resonance_frequency * cmath.exp(1j * phase)
return cls(pole=q, residue=r)
[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)
[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)``.
"""
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
coupling_edot = p.coupling_edot_axes
for ax in range(3):
denom = omega_0[ax] ** 2 - w * w - 1j * gamma[ax] * w
numer = coupling_sq[ax] - 1j * w * coupling_edot[ax]
totals[ax] = totals[ax] + numer / 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, np.ndarray]:
"""Compute the per-axis discrete-time ADE recurrence coefficients.
For each pole and grid axis, returns ``(c1, c2, c3, c4)`` 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{a \\Delta t^2 - b \\Delta t}{D}, \\quad
c_4 = \\frac{b \\Delta t}{D},
where ``a = coupling_sq`` is the ``E`` coupling and ``b = coupling_edot`` is
the ``dE/dt`` coupling. The recurrence uses a forward difference for the
``dE/dt`` term so it stays compatible with the reversible time stepping:
:math:`p_p^{n+1} = c_1 p_p^n + c_2 p_p^{n-1} + c_3 E^n + c_4 E^{n+1}`.
For isotropic poles the three axis columns are identical. For Lorentz and
Drude poles ``b = 0``, so ``c4 = 0`` and ``c3`` reduces to the classic
:math:`K \\Delta t^2 / D`.
Args:
poles: Tuple of poles (may be empty).
dt: Simulation time step (seconds).
Returns:
Four ``numpy`` arrays of shape ``(len(poles), 3)`` with ``c1``, ``c2``,
``c3``, ``c4`` per pole and axis. For an empty pole tuple, returns four
``(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)
c4 = np.zeros((n, 3), dtype=np.float64)
for i, p in enumerate(poles):
omega_0 = p.omega_0_axes
gamma = p.gamma_axes
coupling_sq = p.coupling_sq_axes
coupling_edot = p.coupling_edot_axes
for ax in range(3):
gamma_dt = gamma[ax] * dt
omega0_dt = omega_0[ax] * dt
# The stability bounds only bind 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 = c4 = 0, so its polarization stays identically
# zero and its unused omega_0 / gamma are irrelevant.
axis_active = coupling_sq[ax] != 0.0 or coupling_edot[ax] != 0.0
if axis_active and gamma_dt >= 2.0:
axis_note = "" if p.is_isotropic else f" on axis {'xyz'[ax]}"
raise ValueError(
f"Pole {i} ({type(p).__name__}) has gamma * dt = {gamma_dt:.4g} >= 2{axis_note}; "
"the reversible ADE update requires gamma * dt < 2 (physically gamma * dt << 1). "
"Lower the damping or reduce the time step."
)
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 - coupling_edot[ax] * dt) / denom
c4[i, ax] = (coupling_edot[ax] * dt) / denom
return c1, c2, c3, c4
[docs]
def compute_pole_coefficients(
poles: tuple[Pole, ...],
dt: float,
) -> tuple[np.ndarray, 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:
Four ``numpy`` arrays of shape ``(len(poles),)`` with ``c1``, ``c2``,
``c3``, ``c4``. For an empty pole tuple, returns four 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; "
"use compute_pole_coefficients_per_axis instead."
)
c1, c2, c3, c4 = compute_pole_coefficients_per_axis(poles, dt)
return c1[:, 0], c2[:, 0], c3[:, 0], c4[:, 0]
def susceptibility_from_coefficients(
c1: jax.Array,
c2: jax.Array,
c3: jax.Array,
omega: float,
dt: float,
c4: jax.Array | None = None,
) -> 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,\\\\
a \\Delta t^2 &= (c_3 + c_4) D,\\\\
b \\Delta t &= c_4 D
is applied pointwise, then each pole contributes
.. math::
\\chi_p(\\omega) = \\frac{a - i\\omega b}{\\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. When ``c4`` is
``None`` (Lorentz/Drude) the ``b`` term vanishes and this reduces to the
classic real-numerator Lorentzian.
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.
c4: Optional ADE coefficient array (the ``dE/dt`` coupling), shape
``(num_poles, ...)``. ``None`` is treated as all-zero.
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)
c4 = jnp.zeros_like(c3) if c4 is None else jnp.asarray(c4)
pole_mask = (c1 != 0.0) | (c3 != 0.0) | (c4 != 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)
# a*dt^2 = (c3 + c4)*D, b*dt = c4*D (see compute_pole_coefficients).
a_dt2 = jnp.where(pole_mask, (c3 + c4) * half_factor, 0.0)
b_dt = jnp.where(pole_mask, c4 * half_factor, 0.0)
omega_dt = omega * dt
numer = a_dt2 - 1j * omega_dt * b_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, numer / 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,
c4: jax.Array | 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)
c4_np = np.zeros_like(c3_np) if c4 is None else np.asarray(c4)
inv_eps_np = np.asarray(inv_eps_inf)
omegas_np = np.asarray(omegas, dtype=np.float64)
# 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) | (c4_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)
# a*dt^2 = (c3 + c4)*D, b*dt = c4*D (numerator = a*dt^2 - i*omega*dt*b*dt).
a_dt2 = np.where(pole_mask, (c3_np + c4_np) * half_factor, 0.0)
b_dt = np.where(pole_mask, c4_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:
diag = np.stack([inv_eps_np[0], inv_eps_np[4], inv_eps_np[8]], axis=0)
eps_inf_per_cell = np.mean(1.0 / diag, axis=0)
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)
numer = a_dt2[None, ...] - 1j * omega_dt * b_dt[None, ...]
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, ...], numer / 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.
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,
c4: jax.Array | None = None,
) -> 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.
"""
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, c4=c4)
eps_inf = 1.0 / jnp.asarray(inv_eps)
eps_eff = eps_inf + jnp.real(chi)
return (1.0 / eps_eff).astype(jnp.asarray(inv_eps).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,
c4: jax.Array | 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
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, c4=c4)
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