Source code for fdtdx.objects.detectors.mode

import warnings
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Literal, Self, Sequence

import jax
import jax.numpy as jnp
import numpy as np

from fdtdx import constants
from fdtdx.config import SimulationConfig
from fdtdx.constants import c
from fdtdx.core.axis import get_transverse_axes
from fdtdx.core.jax.pytrees import autoinit, frozen_field, private_field
from fdtdx.core.misc import tilted_polarization_vectors
from fdtdx.core.null import Null
from fdtdx.core.physics.metrics import normalize_by_poynting_flux
from fdtdx.core.physics.modes import compute_mode, compute_mode_symmetry_reduced
from fdtdx.core.wavelength import WaveCharacter
from fdtdx.dispersion import effective_complex_inv_permittivity
from fdtdx.objects.detectors.detector import Detector, DetectorState
from fdtdx.objects.detectors.phasor import PhasorDetector
from fdtdx.typing import SliceTuple3D


def gaussian_mode_fields(
    coordinates: Sequence[jax.Array],
    propagation_axis: int,
    *,
    radius: float,
    direction: Literal["+", "-"],
    polarization_axis: int | None = None,
    fixed_E_polarization_vector: tuple[float, float, float] | None = None,
    fixed_H_polarization_vector: tuple[float, float, float] | None = None,
    azimuth_angle: float = 0.0,
    elevation_angle: float = 0.0,
    divergence_angle: float = 0.0,
    center: tuple[float, float] = (0.0, 0.0),
    wavelength: float | jax.Array,
    refractive_index: float | jax.Array = 1.0,
    dtype: jnp.dtype = jnp.float32,
) -> tuple[jax.Array, jax.Array]:
    """Build the transverse profile of a Gaussian beam on a detector / source plane.

    The beam's cross-section at a single plane — no ``z`` dependence, waist evolution or
    Gouy phase. ``radius`` is the spot size there and ``divergence_angle`` the wavefront
    curvature; paraxially this is ``exp(i k r^2 / 2q)`` with
    ``1/q = tan(divergence_angle)/radius + 2i/(k radius^2)`` and the medium wavenumber
    ``k = 2 pi n / wavelength``.

    Polarization and propagation direction follow :class:`~fdtdx.GaussianPlaneSource`
    exactly, via :func:`~fdtdx.core.misc.tilted_polarization_vectors`. Phase is zero at the
    beam center; without tilt or divergence the mode is real. Assumes ``mu_r = 1`` —
    ``|H| = n |E|`` and ``k`` derive from the permittivity alone.

    Args:
        coordinates: ``(X, Y, Z)`` cell-center meshgrids of shape ``grid_shape`` (singleton
            on ``propagation_axis``), center-origin like the grid.
        propagation_axis: Physical propagation axis (0=x, 1=y, 2=z).
        radius: Positive ``1/e`` amplitude radius at this plane, in metres, measured in the
            beam's own cross-section.
        direction: ``"+"`` (forward) or ``"-"`` (backward) along ``propagation_axis``.
        polarization_axis: Transverse axis the E field points along. Mutually exclusive
            with ``fixed_E/H_polarization_vector``. Defaults to the first transverse axis.
        fixed_E_polarization_vector: Explicit E polarization 3-vector (mirrors the source).
        fixed_H_polarization_vector: Explicit H polarization 3-vector (mirrors the source).
        azimuth_angle: Propagation tilt around the vertical axis, in degrees.
        elevation_angle: Propagation tilt around the horizontal axis, in degrees.
        divergence_angle: Wavefront cone half-angle at this plane, in degrees, taken at the
            ``1/e`` radius: ``tan(angle) = radius / R``. ``0.0`` is flat phase (collimated,
            or a plane at the waist); positive diverges, negative converges. Far from the
            waist this equals the far-field divergence ``lambda / (pi w_0 n)``.
        center: Transverse center offset ``(off_t0, off_t1)`` in metres, ascending axes.
        wavelength: Vacuum wavelength in metres, setting the tilt ramp and the curvature.
        refractive_index: Local medium index, for the ``|H| = n |E|`` ratio and ``k``.
        dtype: Float dtype used to build polarization/rotation vectors.

    Returns:
        ``(mode_E, mode_H)``, each ``(3, *grid_shape)``; complex under tilt or divergence.

    Raises:
        ValueError: If ``radius`` is not positive, or ``polarization_axis`` conflicts with
            an explicit polarization vector / is not transverse.
    """
    if radius <= 0:
        raise ValueError(f"radius must be positive, got {radius}")
    if polarization_axis is not None and (
        fixed_E_polarization_vector is not None or fixed_H_polarization_vector is not None
    ):
        raise ValueError("Specify either polarization_axis or fixed_E/H_polarization_vector, not both")
    if fixed_E_polarization_vector is None and fixed_H_polarization_vector is None:
        pol_axis = get_transverse_axes(propagation_axis)[0] if polarization_axis is None else polarization_axis
        if pol_axis == propagation_axis:
            raise ValueError(
                f"polarization_axis ({pol_axis}) must be transverse to the propagation axis ({propagation_axis})"
            )
        e_vec = [0.0, 0.0, 0.0]
        e_vec[pol_axis] = 1.0
        fixed_E_polarization_vector = (e_vec[0], e_vec[1], e_vec[2])

    # E/H polarization unit vectors and the (tilted) wave vector — same derivation as the
    # plane sources (degrees -> radians; a zero angle is the identity rotation).
    e_pol, h_pol, wave_vector = tilted_polarization_vectors(
        direction=direction,
        propagation_axis=propagation_axis,
        fixed_E_polarization_vector=fixed_E_polarization_vector,
        fixed_H_polarization_vector=fixed_H_polarization_vector,
        azimuth_radians=jnp.asarray(np.deg2rad(azimuth_angle), dtype=dtype),
        elevation_radians=jnp.asarray(np.deg2rad(elevation_angle), dtype=dtype),
        dtype=dtype,
    )
    is_tilted = azimuth_angle != 0.0 or elevation_angle != 0.0
    is_curved = divergence_angle != 0.0

    t0, t1 = get_transverse_axes(propagation_axis)
    transverse_0 = coordinates[t0] - center[0]
    transverse_1 = coordinates[t1] - center[1]

    # Radius measured in the beam's own cross-section, i.e. projected onto the plane
    # orthogonal to the wave vector (same footprint as GaussianPlaneSource). Since the
    # propagation-axis coordinate is zero here, |r_perp|^2 = |r|^2 - (r . k_hat)^2.
    along_k = wave_vector[t0] * transverse_0 + wave_vector[t1] * transverse_1
    r_perp_squared = transverse_0**2 + transverse_1**2
    if is_tilted:
        r_perp_squared = r_perp_squared - along_k**2

    amplitude = jnp.exp(-r_perp_squared / (radius**2))

    if is_tilted or is_curved:
        wavenumber = 2.0 * jnp.pi * refractive_index / wavelength
        phase_arg = jnp.zeros_like(amplitude)
        if is_tilted:
            phase_arg = phase_arg + wavenumber * along_k  # tilted wavefront ramp
        if is_curved:
            inv_curvature_radius = float(np.tan(np.deg2rad(divergence_angle))) / radius
            phase_arg = phase_arg + 0.5 * wavenumber * inv_curvature_radius * r_perp_squared
        amplitude = amplitude * jnp.exp(1j * phase_arg)

    mode_E = amplitude[None, ...] * e_pol[:, None, None, None]
    mode_H = amplitude[None, ...] * (refractive_index * h_pol)[:, None, None, None]
    return mode_E, mode_H


def gaussian_mode_function(
    *,
    radius: float,
    direction: Literal["+", "-"],
    polarization_axis: int | None = None,
    fixed_E_polarization_vector: tuple[float, float, float] | None = None,
    fixed_H_polarization_vector: tuple[float, float, float] | None = None,
    azimuth_angle: float = 0.0,
    elevation_angle: float = 0.0,
    divergence_angle: float = 0.0,
    center: tuple[float, float] = (0.0, 0.0),
) -> Callable[..., tuple[jax.Array, jax.Array]]:
    """Return a ``mode_function`` for :class:`CustomModeOverlapDetector` (analytic Gaussian).

    The returned callable derives the local refractive index from the permittivity slice and
    delegates to :func:`gaussian_mode_fields`; see that function for the arguments,
    including the ``mu_r = 1`` assumption.
    """

    def _mode_function(
        *,
        coordinates: Sequence[jax.Array],
        frequency: float,
        propagation_axis: int,
        inv_permittivity: jax.Array,
    ) -> tuple[jax.Array, jax.Array]:
        """Evaluate the Gaussian at one frequency, taking the index from the plane."""
        refractive_index = jnp.sqrt(jnp.mean(1.0 / inv_permittivity))
        return gaussian_mode_fields(
            coordinates,
            propagation_axis,
            radius=radius,
            direction=direction,
            polarization_axis=polarization_axis,
            fixed_E_polarization_vector=fixed_E_polarization_vector,
            fixed_H_polarization_vector=fixed_H_polarization_vector,
            azimuth_angle=azimuth_angle,
            elevation_angle=elevation_angle,
            divergence_angle=divergence_angle,
            center=center,
            wavelength=c / frequency,
            refractive_index=refractive_index,
            dtype=inv_permittivity.dtype,
        )

    return _mode_function


@autoinit
class BaseModeOverlapDetector(PhasorDetector, ABC):
    """Abstract base for mode-overlap detectors.

    Owns everything that is independent of *how* the reference mode is produced: the
    stored reference mode fields (``_mode_E`` / ``_mode_H`` of shape
    ``(num_freqs, 3, *spatial)``), the detector-plane face-area weights, and the overlap
    integral itself (:meth:`compute_overlap` / :meth:`compute_overlap_to_mode`).

    Subclasses only implement :meth:`_compute_mode_fields`, which returns the reference
    mode for a single frequency on the detector plane. :class:`ModeOverlapDetector` solves
    it with the waveguide mode solver; :class:`CustomModeOverlapDetector` /
    :class:`GaussianModeOverlapDetector` evaluate a user-supplied / analytic mode instead.

    ``compute_overlap()`` returns a complex array of shape ``(num_freqs,)``, where
    ``num_freqs = len(wave_characters)``.
    """

    #: Cannot be specified here since the detector needs all components.
    components: Sequence[Literal["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"]] = frozen_field(
        default=("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"),
        init=False,  # in this detector, we always want all components. Do not give user a choice
    )

    #: Cannot be specified here since plotting a single scalar is useless.
    plot: bool = frozen_field(default=False, init=False)  # single scalar is useless for plotting

    _mode_E: jax.Array = private_field()
    _mode_H: jax.Array = private_field()
    _mode_neff: jax.Array = private_field()  # not required for detection, used for inspection
    _cached_face_area_weights: jax.Array = private_field()

    #: Explicit propagation axis for an extruded 2-D simulation. It is only
    #: needed when both the detector normal and an invariant domain axis have
    #: one cell, making shape-based inference ambiguous.
    fixed_propagation_axis: int | None = frozen_field(default=None)

    @property
    def propagation_axis(self) -> int:
        """Physical axis normal to the detector plane, i.e. the singleton grid axis."""
        if self.fixed_propagation_axis is not None:
            if self.fixed_propagation_axis not in (0, 1, 2):
                raise ValueError("fixed_propagation_axis must be 0, 1, or 2")
            if self.grid_shape[self.fixed_propagation_axis] != 1:
                raise ValueError(
                    "fixed_propagation_axis must select a one-cell detector dimension, "
                    f"got axis {self.fixed_propagation_axis} for shape {self.grid_shape}"
                )
            return self.fixed_propagation_axis
        if sum([a == 1 for a in self.grid_shape]) != 1:
            raise ValueError(
                "Invalid ModeOverlapDetector shape: propagation axis is ambiguous; "
                "set fixed_propagation_axis "
                f"for shape {self.grid_shape}"
            )
        return self.grid_shape.index(1)

    def place_on_grid(
        self: Self,
        grid_slice_tuple: SliceTuple3D,
        config: SimulationConfig,
        key: jax.Array,
    ) -> Self:
        """Place the detector and cache the plane's face-area weights."""
        self = super().place_on_grid(
            grid_slice_tuple=grid_slice_tuple,
            config=config,
            key=key,
        )
        grid = self._config.resolved_grid
        if grid is not None:
            weights = grid.face_area(axis=self.propagation_axis, slice_tuple=self.grid_slice_tuple)
        else:
            spacing = self._config.uniform_spacing()
            weights = jnp.ones(self.grid_shape, dtype=jnp.float32) * spacing * spacing
        weights = weights / weights.mean()  # consistent with scaling in compute_mode
        self = self.aset("_cached_face_area_weights", weights, create_new_ok=True)
        return self

    def _face_area_weights(self) -> jax.Array:
        """Return detector-plane face areas for mode-overlap integration."""
        return self._cached_face_area_weights

    def _plane_coordinates(self) -> tuple[jax.Array, jax.Array, jax.Array]:
        """Return ``(X, Y, Z)`` cell-center coordinate meshgrids for the detector plane.

        Each array has shape ``grid_shape`` (singleton on the propagation axis).  Used by
        subclasses that evaluate an analytic mode profile on the actual placed grid. When
        the grid is resolved the coordinates follow the center-origin convention (#363);
        the uniform fallback (unresolved policy, only hit in lightweight tests) is
        corner-relative.
        """
        grid = self._config.resolved_grid
        axis_centers: list[jax.Array] = []
        for axis in range(3):
            lower, upper = self.grid_slice_tuple[axis]
            if grid is not None:
                centers = jnp.asarray(grid.centers(axis))[lower:upper]
            else:
                spacing = self._config.uniform_spacing()
                centers = (jnp.arange(lower, upper) + 0.5) * spacing
            axis_centers.append(centers)
        x_coords, y_coords, z_coords = jnp.meshgrid(*axis_centers, indexing="ij")
        return x_coords, y_coords, z_coords

    @staticmethod
    def _as_real_inv_permittivity(inv_permittivity_slice: jax.Array) -> jax.Array:
        """Reduce a (possibly complex) effective inverse permittivity to ``1/Re(eps)``.

        :meth:`apply` hands subclasses the full complex ``1/eps(omega_c)`` so the waveguide
        mode solver can model material loss. Analytic reference modes only use the real
        index (loss is integrated by the ADE / conductivity updates, not by the reference
        profile), so they reduce to ``1/Re(eps)`` — the same value
        :func:`~fdtdx.dispersion.effective_inv_permittivity` returns. Real input is
        returned unchanged.
        """
        if jnp.iscomplexobj(inv_permittivity_slice):
            return 1.0 / jnp.real(1.0 / inv_permittivity_slice)
        return inv_permittivity_slice

    @abstractmethod
    def _compute_mode_fields(
        self,
        *,
        wave_character: WaveCharacter,
        inv_permittivity_slice: jax.Array,
        inv_permeability_slice: jax.Array | float,
    ) -> tuple[jax.Array, jax.Array, jax.Array]:
        """Produce the reference mode ``(mode_E, mode_H, mode_neff)`` for one frequency.

        ``mode_E`` / ``mode_H`` must have shape ``(3, *grid_shape)`` (singleton on the
        propagation axis); ``mode_neff`` is a complex/real scalar used only for
        inspection. ``inv_permittivity_slice`` is already restricted to the detector plane
        and, in a dispersive or conductive medium, corrected to the full complex effective
        permittivity at the frequency's carrier (see :meth:`apply`). Subclasses that only
        support a real index reduce it via :meth:`_as_real_inv_permittivity`.
        """
        raise NotImplementedError

    def apply(
        self,
        key: jax.Array,
        inv_permittivities: jax.Array,
        inv_permeabilities: jax.Array | float,
        dispersive_c1: jax.Array | None = None,
        dispersive_c2: jax.Array | None = None,
        dispersive_c3: jax.Array | None = None,
        electric_conductivity: jax.Array | None = None,
        dispersive_c4: jax.Array | None = None,
    ) -> Self:
        """Precompute and store the reference mode fields for every wave character."""
        del key
        inv_permittivity_slice = inv_permittivities[:, *self.grid_slice]
        if isinstance(inv_permeabilities, jax.Array) and inv_permeabilities.ndim > 0:
            inv_permeability_slice = inv_permeabilities[:, *self.grid_slice]
        else:
            inv_permeability_slice = inv_permeabilities

        c1_slice = c2_slice = c3_slice = c4_slice = None
        if dispersive_c1 is not None and dispersive_c2 is not None and dispersive_c3 is not None:
            c1_slice = dispersive_c1[:, :, *self.grid_slice]
            c2_slice = dispersive_c2[:, :, *self.grid_slice]
            c3_slice = dispersive_c3[:, :, *self.grid_slice]
            c4_slice = None if dispersive_c4 is None else dispersive_c4[:, :, *self.grid_slice]

        # The reference mode is solved against the FULL complex epsilon at each
        # carrier frequency (eps_inf + chi(omega) + i*sigma/(eps0*omega)), so the
        # overlap basis and effective index reflect material loss. With no loss
        # the complex helper is skipped and the real lossless path is preserved.
        sigma_slice = None if electric_conductivity is None else electric_conductivity[:, *self.grid_slice]
        conductivity_spacing = (
            None if sigma_slice is None else constants.c * self._config.time_step_duration / self._config.courant_number
        )

        all_mode_Es: list[jax.Array] = []
        all_mode_Hs: list[jax.Array] = []
        all_mode_neffs: list[jax.Array] = []
        for wc in self.wave_characters:
            inv_eps_i = inv_permittivity_slice
            if c1_slice is not None or sigma_slice is not None:
                inv_eps_i = effective_complex_inv_permittivity(
                    inv_eps=inv_permittivity_slice,
                    omega=2.0 * np.pi * wc.get_frequency(),
                    dt=self._config.time_step_duration,
                    c1=c1_slice,
                    c2=c2_slice,
                    c3=c3_slice,
                    c4=c4_slice,
                    electric_conductivity=sigma_slice,
                    conductivity_spacing=conductivity_spacing,
                )
            mode_E, mode_H, mode_neff = self._compute_mode_fields(
                wave_character=wc,
                inv_permittivity_slice=inv_eps_i,
                inv_permeability_slice=inv_permeability_slice,
            )
            all_mode_Es.append(mode_E)
            all_mode_Hs.append(mode_H)
            all_mode_neffs.append(mode_neff)

        self = self.aset("_mode_E", jnp.stack(all_mode_Es, axis=0), create_new_ok=True)
        self = self.aset("_mode_H", jnp.stack(all_mode_Hs, axis=0), create_new_ok=True)
        self = self.aset("_mode_neff", jnp.stack(all_mode_neffs, axis=0), create_new_ok=True)
        return self

    def compute_overlap_to_mode(
        self,
        state: DetectorState,
        mode_E: jax.Array,
        mode_H: jax.Array,
        wave_character_index: int = 0,
    ) -> jax.Array:
        """Compute the overlap integral of *one* mode against phasors at ``wave_character_index``.

        Args:
            state: Detector state holding the phasor array of shape
                ``(1, num_freqs, 6, *spatial)``.
            mode_E: Electric mode field of shape ``(3, *spatial)``.
            mode_H: Magnetic mode field of shape ``(3, *spatial)``.
            wave_character_index: Index into the phasor frequency axis to use.

        Returns:
            Complex scalar overlap coefficient.
        """
        # shape (time step, num_freqs, num_components, *spatial)
        # time steps is always 1 and num_components always 6
        phasors = state["phasor"]
        phasors_E, phasors_H = phasors[0, wave_character_index, :3], phasors[0, wave_character_index, 3:]

        E_cross_H_star_sim = jnp.cross(
            mode_E,
            jnp.conj(phasors_H),
            axis=0,
        )[self.propagation_axis]

        E_star_cross_H_sim = jnp.cross(
            jnp.conj(phasors_E),
            mode_H,
            axis=0,
        )[self.propagation_axis]

        integrand = E_cross_H_star_sim + E_star_cross_H_sim
        integrand = integrand * self._face_area_weights()
        alpha_coeff = jnp.sum(integrand)

        # in pulsed mode return unscaled coefficient
        if self.scaling_mode != "pulse":
            alpha_coeff = alpha_coeff / 4.0

        return alpha_coeff

    def compute_overlap(
        self,
        state: DetectorState,
    ) -> jax.Array:
        """Compute mode overlaps at every frequency in ``wave_characters``.

        Returns:
            Complex array of shape ``(num_freqs,)``.
        """
        if isinstance(self._mode_E, Null) or isinstance(self._mode_H, Null):
            raise Exception("Need to call apply on the mode-overlap detector before calling compute_overlap!")
        overlaps = [
            self.compute_overlap_to_mode(
                state=state,
                mode_E=self._mode_E[i],
                mode_H=self._mode_H[i],
                wave_character_index=i,
            )
            for i in range(len(self.wave_characters))
        ]
        return jnp.stack(overlaps, axis=0)


[docs] @autoinit class ModeOverlapDetector(BaseModeOverlapDetector): """ Detector for measuring the overlap of a waveguide mode with the simulation fields. This detector computes the overlap integral at every frequency in ``wave_characters``, enabling broadband frequency-domain analysis of the electromagnetic fields. The reference mode is obtained from the waveguide mode solver (``compute_mode``). For a user-supplied or analytic reference mode (e.g. a Gaussian beam) use :class:`CustomModeOverlapDetector` or :class:`GaussianModeOverlapDetector` instead; both share the same overlap machinery via :class:`BaseModeOverlapDetector`. The mode overlap is calculated by integrating the cross product of the mode fields with the simulation fields over a cross-sectional plane. This is useful for analyzing waveguide coupling efficiency, transmission coefficients, and modal decomposition of electromagnetic fields. ``compute_overlap()`` returns a complex array of shape ``(num_freqs,)``, where ``num_freqs = len(wave_characters)``. """ #: Direction of mode propagation, either "+" (forward) or "-" (backward). #: Determines which direction along the waveguide axis the mode is assumed to propagate. direction: Literal["+", "-"] = frozen_field() #: Index of the waveguide mode to use for overlap calculation. #: Defaults to 0 (fundamental mode). Higher indices correspond to higher-order modes. mode_index: int = frozen_field(default=0) #: Optional polarization filter for the mode calculation. #: Can be "te" (transverse electric), "tm" (transverse magnetic), or None (no filtering). #: When specified, only modes of the given polarization type are considered. Defaults to None. filter_pol: Literal["te", "tm"] | None = frozen_field(default=None) #: Bend radius of the waveguide in meters. When set, the mode solver accounts for the conformal #: transformation introduced by the bend. Must be set together with bend_axis. Defaults to None #: (straight waveguide). bend_radius: float | None = frozen_field(default=None) #: Physical axis index (0=x, 1=y, 2=z) pointing from the waveguide center toward the center of #: curvature. Must differ from the propagation axis. Required when bend_radius is set. bend_axis: int | None = frozen_field(default=None) #: Symmetry-plane condition at the min edge of each transverse axis (the two non-propagation #: physical axes, in increasing-index order): ``0`` = PEC mirror (electric wall, the default), #: ``1`` = PMC mirror (magnetic wall). Set this only for a **hand-built** half/quarter domain #: (your own PEC/PMC boundary at the min edge), where it asks the mode solver for its own #: symmetric solve; it must then match the corresponding ModePlaneSource. It is ignored when #: ``config.symmetry`` performs the reduction: there the reference mode is solved on the mirrored #: full cross-section and restricted, exactly like the mode source, so the overlap stays #: consistent automatically. symmetry: tuple[int, int] = frozen_field(default=(0, 0)) def place_on_grid( self: Self, grid_slice_tuple: SliceTuple3D, config: SimulationConfig, key: jax.Array, ) -> Self: """Place the detector and validate the bend arguments.""" self = super().place_on_grid( grid_slice_tuple=grid_slice_tuple, config=config, key=key, ) if (self.bend_radius is None) != (self.bend_axis is None): raise ValueError("bend_radius and bend_axis must both be set or both be None") if self.bend_axis is not None and self.bend_axis == self.propagation_axis: raise ValueError( f"bend_axis ({self.bend_axis}) must differ from the propagation axis ({self.propagation_axis})" ) return self def _transverse_edge_coordinates(self) -> tuple[jax.Array, jax.Array] | None: """Return physical transverse edge coordinates for the mode solver. Tidy3D can solve modes on rectilinear non-uniform grids when supplied with edge-coordinate arrays. Returning ``None`` keeps the uniform scalar spacing path for legacy configurations and older tests. """ grid = self._config.resolved_grid if grid is None: return None transverse_edges = [] for axis in range(3): if axis == self.propagation_axis: continue lower, upper = self.grid_slice_tuple[axis] transverse_edges.append(grid.edges(axis)[lower : upper + 1]) e0, e1 = transverse_edges return e0, e1 def _mode_solver_resolution(self) -> float: """Return scalar resolution only for legacy uniform mode-solver setup. ``compute_mode`` ignores this value when explicit transverse coordinates are supplied. For non-uniform grids we pass a harmless finite value so the compatibility argument does not force a uniform-grid check. """ if self._config.has_nonuniform_grid: assert self._config.resolved_grid is not None return self._config.resolved_grid.min_spacing return self._config.uniform_spacing() def _compute_mode_fields( self, *, wave_character: WaveCharacter, inv_permittivity_slice: jax.Array, inv_permeability_slice: jax.Array | float, ) -> tuple[jax.Array, jax.Array, jax.Array]: """Solve the reference mode with the waveguide mode solver.""" mirrored_axes = self.symmetry_mirror_axes(exclude_axis=self.propagation_axis) if mirrored_axes: # Symmetry-reduced cross-section: mirror it back to the full one, solve there and # restrict — the same route ModePlaneSource takes, so source and reference mode agree. return compute_mode_symmetry_reduced( mirrored_axes=mirrored_axes, walls={axis: self._config.symmetry[axis] for axis in mirrored_axes}, frequency=wave_character.get_frequency(), inv_permittivities=inv_permittivity_slice, inv_permeabilities=inv_permeability_slice, resolution=self._mode_solver_resolution(), direction=self.direction, mode_index=self.mode_index, filter_pol=self.filter_pol, dtype=self._config.dtype, bend_radius=self.bend_radius, bend_axis=self.bend_axis, transverse_coords=self._transverse_edge_coordinates(), object_name=self.name, fixed_propagation_axis=self.propagation_axis, ) return compute_mode( frequency=wave_character.get_frequency(), inv_permittivities=inv_permittivity_slice, inv_permeabilities=inv_permeability_slice, resolution=self._mode_solver_resolution(), direction=self.direction, mode_index=self.mode_index, filter_pol=self.filter_pol, dtype=self._config.dtype, bend_radius=self.bend_radius, bend_axis=self.bend_axis, symmetry=self.symmetry, transverse_coords=self._transverse_edge_coordinates(), fixed_propagation_axis=self.propagation_axis, )
@autoinit class TimeDomainModeOverlapDetector(ModeOverlapDetector): """Record one directional waveguide-mode coordinate versus time. The detector solves the same fixed reference mode as :class:`ModeOverlapDetector`, but applies the reciprocal power-overlap integral directly to every real-valued FDTD sample instead of first accumulating a frequency-domain phasor. Its ``fields`` output has shape ``(recorded_time_steps, 1)`` and can therefore replace a reduced one-point :class:`~fdtdx.FieldDetector` in temporal objectives without retaining an entire transverse field plane. Exactly one ``wave_character`` is required. Its frequency only defines the fixed transverse reference mode; no carrier is mixed into the stored trace. The reference eigenvector is phase-aligned by its largest electric component and projected to a real quadrature, which is appropriate for a lossless mode used with real time-domain fields. Use :class:`ModeOverlapDetector` when a complex, frequency-domain coefficient is required (for example in a lossy guide). """ dtype: jnp.dtype = frozen_field(default=jnp.float32) #: ``"directional"`` uses the reciprocal E/H power overlap and selects #: one propagation direction. ``"electric"`` records the electric-mode #: inner product, making the detector exactly reciprocal to an impressed #: electric :class:`~fdtdx.ModeProfileCurrentSource` on the same plane. overlap_kind: Literal["directional", "electric"] = frozen_field(default="directional") def __post_init__(self) -> None: if self.dtype not in (jnp.float32, jnp.float64): raise ValueError("TimeDomainModeOverlapDetector requires a real floating dtype") if len(self.wave_characters) != 1: raise ValueError("TimeDomainModeOverlapDetector requires exactly one wave_character") if self.overlap_kind not in ("directional", "electric"): raise ValueError("overlap_kind must be 'directional' or 'electric'") def _calculate_on_list(self) -> list[bool]: """Use the ordinary time-domain switch, not phasor DFT thinning.""" return Detector._calculate_on_list(self) def _num_latent_time_steps(self) -> int: return Detector._num_latent_time_steps(self) def _shape_dtype_single_time_step(self) -> dict[str, jax.ShapeDtypeStruct]: return {"fields": jax.ShapeDtypeStruct(shape=(1,), dtype=self.dtype)} def update( self, time_step: jax.Array, E: jax.Array, H: jax.Array, state: DetectorState, inv_permittivity: jax.Array, inv_permeability: jax.Array | float, ) -> DetectorState: del inv_permittivity, inv_permeability if isinstance(self._mode_E, Null) or isinstance(self._mode_H, Null): raise RuntimeError("apply() must initialize the reference mode before recording") mode_E_complex = self._mode_E[0] mode_H_complex = self._mode_H[0] flat_e = mode_E_complex.reshape(-1) dominant = flat_e[jnp.argmax(jnp.abs(flat_e))] rotation = jnp.exp(-1j * jnp.angle(dominant)) mode_E = jnp.real(mode_E_complex * rotation) mode_H = jnp.real(mode_H_complex * rotation) if self.overlap_kind == "electric": overlap = jnp.sum(jnp.sum(mode_E * E, axis=0) * self._face_area_weights()) else: # Lorentz-reciprocal directional overlap. For an exact forward # copy of the reference mode the two terms add; for its backward # partner the magnetic-field sign reversal makes them cancel. mode_e_cross_h = jnp.cross(mode_E, H, axis=0)[self.propagation_axis] e_cross_mode_h = jnp.cross(E, mode_H, axis=0)[self.propagation_axis] overlap = 0.5 * jnp.sum((mode_e_cross_h + e_cross_mode_h) * self._face_area_weights()) arr_idx = self._time_step_to_arr_idx[time_step] fields = state["fields"].at[arr_idx, 0].set(overlap.astype(self.dtype)) return {"fields": fields} @autoinit class CustomModeOverlapDetector(BaseModeOverlapDetector): """Mode-overlap detector using a user-provided reference mode. Instead of solving the mode with the waveguide mode solver, the reference mode is produced by ``mode_function`` — a callable evaluated on the detector plane during :meth:`apply`. This enables overlap against an arbitrary mode (e.g. an analytic Gaussian beam, a fiber mode, or a mode imported from another tool) and avoids the tidy3d mode-solver dependency. ``mode_function`` is called once per frequency with keyword arguments:: mode_function( coordinates, # (X, Y, Z) cell-center meshgrids, each (3==None) shape grid_shape frequency, # float, Hz propagation_axis, # int, 0/1/2 inv_permittivity, # effective eps slice on the plane (n_comp, *grid_shape) ) -> (mode_E, mode_H) # each (3, *grid_shape) and must return the E and H fields in FDTDX's :math:`\\eta_0`-normalized convention. Use :func:`gaussian_mode_function` for a ready-made analytic Gaussian, or :class:`GaussianModeOverlapDetector` for the same as a configurable detector class. Dispersive media are handled by the shared :meth:`BaseModeOverlapDetector.apply`: in a dispersive simulation ``inv_permittivity`` is the *effective* inverse permittivity :math:`1/\\mathrm{Re}(\\varepsilon_\\infty + \\chi(\\omega_c))` at the wave character's carrier frequency (same correction as ``ModeOverlapDetector`` / ``ModePlaneSource``), not :math:`\\varepsilon_\\infty`. A frequency-aware ``mode_function`` therefore sees the true medium index per frequency. """ #: Callable producing the reference ``(mode_E, mode_H)`` on the detector plane. #: See the class docstring for the exact keyword-argument signature. mode_function: Callable[..., tuple[jax.Array, jax.Array]] = frozen_field() #: Whether to renormalize the provided mode to unit Poynting flux over the detector #: plane (matching what the mode solver does). Defaults to True. normalize: bool = frozen_field(default=True) def _compute_mode_fields( self, *, wave_character: WaveCharacter, inv_permittivity_slice: jax.Array, inv_permeability_slice: jax.Array | float, ) -> tuple[jax.Array, jax.Array, jax.Array]: """Evaluate the user-supplied ``mode_function`` on the detector plane.""" del inv_permeability_slice coordinates = self._plane_coordinates() inv_permittivity_slice = self._as_real_inv_permittivity(inv_permittivity_slice) mode_E, mode_H = self.mode_function( coordinates=coordinates, frequency=wave_character.get_frequency(), propagation_axis=self.propagation_axis, inv_permittivity=inv_permittivity_slice, ) # Nominal effective index for inspection only (mean medium index on the plane). mode_neff = jnp.sqrt(jnp.mean(1.0 / inv_permittivity_slice)) if self.normalize: mode_E, mode_H = normalize_by_poynting_flux( mode_E, mode_H, axis=self.propagation_axis, area_weights=self._face_area_weights(), ) return mode_E, mode_H, mode_neff @autoinit class GaussianModeOverlapDetector(BaseModeOverlapDetector): """Mode-overlap detector using an analytic Gaussian profile as the reference mode. Wrapper around :func:`gaussian_mode_fields`. Everything is stated *at the detector plane*: ``mode_radius`` is the spot size there and ``divergence_angle`` the wavefront curvature there, so a collimated beam (the default), a tilted beam, and a diverging or converging one are all expressible without a mode solver. The reference is a transverse cross-section, not a propagating beam. Polarization and propagation angle are configured exactly like :class:`~fdtdx.GaussianPlaneSource`. Assumes ``mu_r = 1``. Dispersion-aware: ``n`` (and the wavenumber ``k = n ω/c``) come from the effective permittivity :math:`\\mathrm{Re}(\\varepsilon_\\infty + \\chi(\\omega_c))` at each wave character's carrier frequency via :meth:`BaseModeOverlapDetector.apply`, not :math:`\\varepsilon_\\infty`. """ #: Gaussian ``1/e`` amplitude radius *at the detector plane*, in metres. Must be #: positive. This is the spot size on this plane, not the waist of the beam. mode_radius: float = frozen_field() #: Direction of propagation, "+" (forward) or "-" (backward) along the plane normal. direction: Literal["+", "-"] = frozen_field() #: Convenience polarization selector — transverse axis the E field points along. #: Mutually exclusive with ``fixed_E/H_polarization_vector``. Defaults to the first #: transverse axis (ascending index order). polarization_axis: int | None = frozen_field(default=None) #: Explicit electric polarization 3-vector (mirrors ``GaussianPlaneSource``). fixed_E_polarization_vector: tuple[float, float, float] | None = frozen_field(default=None) #: Explicit magnetic polarization 3-vector (mirrors ``GaussianPlaneSource``). fixed_H_polarization_vector: tuple[float, float, float] | None = frozen_field(default=None) #: Propagation tilt around the vertical axis, in degrees (off-normal incidence). azimuth_angle: float = frozen_field(default=0.0) #: Propagation tilt around the horizontal axis, in degrees (off-normal incidence). elevation_angle: float = frozen_field(default=0.0) #: Wavefront cone half-angle at the detector plane, in degrees, taken at the ``1/e`` #: radius: ``tan(angle) = mode_radius / R``. ``0.0`` (default) is a flat phase front — #: a collimated beam, or a plane at the beam waist. Positive diverges, negative #: converges. Far from the waist this equals the far-field divergence #: ``lambda / (pi w_0 n)``. Curves the wavefront; unlike ``azimuth_angle`` / #: ``elevation_angle`` it does not tilt the propagation direction. divergence_angle: float = frozen_field(default=0.0) #: Transverse center offset ``(off_t0, off_t1)`` in metres for the two transverse axes #: (ascending index order), in the same physical coordinates as the grid. center: tuple[float, float] = frozen_field(default=(0.0, 0.0)) #: Whether to renormalize the mode to unit Poynting flux over the detector plane. normalize: bool = frozen_field(default=True) def place_on_grid( self: Self, grid_slice_tuple: SliceTuple3D, config: SimulationConfig, key: jax.Array, ) -> Self: """Place the detector, validate ``mode_radius`` and warn on a truncated beam.""" self = super().place_on_grid(grid_slice_tuple=grid_slice_tuple, config=config, key=key) if self.mode_radius <= 0: raise ValueError(f"mode_radius must be positive, got {self.mode_radius}") self._warn_if_truncated() return self def _warn_if_truncated(self) -> None: """Warn if the detector plane clips the reference mode above ~1% amplitude. The overlap only integrates over the detector's own footprint, so a beam wider than the plane is silently under-reported. A clean measurement needs a transverse half-extent of ``>~ 3 x mode_radius`` (edge amplitude ``~0.01%``). Assumes the beam is centered, so this is the best-case truncation amplitude. """ grid = self._config.resolved_grid half_extents = [] for axis in get_transverse_axes(self.propagation_axis): lower, upper = self.grid_slice_tuple[axis] if grid is not None: extent = grid.axis_extent(axis, (lower, upper)) else: extent = (upper - lower) * self._config.uniform_spacing() half_extents.append(0.5 * extent) edge_distance = min(half_extents) truncation_amplitude = float(np.exp(-((edge_distance / self.mode_radius) ** 2))) if truncation_amplitude > 0.01: warnings.warn( f"GaussianModeOverlapDetector '{self.name}': the detector plane truncates the reference " f"mode at {truncation_amplitude * 100:.1f}% amplitude (nearest edge at " f"{edge_distance / self.mode_radius:.2f} x mode_radius), so the overlap under-reports " f"the coupled power. Enlarge the detector to >~ 3 x mode_radius transversely.", UserWarning, stacklevel=2, ) def _compute_mode_fields( self, *, wave_character: WaveCharacter, inv_permittivity_slice: jax.Array, inv_permeability_slice: jax.Array | float, ) -> tuple[jax.Array, jax.Array, jax.Array]: """Evaluate the analytic Gaussian profile on the detector plane.""" del inv_permeability_slice coordinates = self._plane_coordinates() inv_permittivity_slice = self._as_real_inv_permittivity(inv_permittivity_slice) refractive_index = jnp.sqrt(jnp.mean(1.0 / inv_permittivity_slice)) mode_E, mode_H = gaussian_mode_fields( coordinates, self.propagation_axis, radius=self.mode_radius, direction=self.direction, polarization_axis=self.polarization_axis, fixed_E_polarization_vector=self.fixed_E_polarization_vector, fixed_H_polarization_vector=self.fixed_H_polarization_vector, azimuth_angle=self.azimuth_angle, elevation_angle=self.elevation_angle, divergence_angle=self.divergence_angle, center=self.center, wavelength=wave_character.get_wavelength(), refractive_index=refractive_index, dtype=self._config.dtype, ) if self.normalize: mode_E, mode_H = normalize_by_poynting_flux( mode_E, mode_H, axis=self.propagation_axis, area_weights=self._face_area_weights(), ) return mode_E, mode_H, refractive_index