Source code for fdtdx.objects.detectors.poynting_flux

from typing import ClassVar, Literal, Self, Sequence

import jax
import jax.numpy as jnp

from fdtdx.config import SimulationConfig
from fdtdx.core.jax.pytrees import autoinit, frozen_field, private_field
from fdtdx.core.physics.metrics import compute_poynting_flux, net_poynting_flux_through_box
from fdtdx.objects.detectors.detector import Detector, DetectorState
from fdtdx.objects.detectors.phasor import PhasorDetector
from fdtdx.typing import SliceTuple3D


def _resolve_face_area_weights(
    config: SimulationConfig,
    slice_tuple: SliceTuple3D,
    axis: int,
    dtype: jnp.dtype,
) -> jax.Array:
    """Per-cell face-area weights for a face normal to ``axis`` over ``slice_tuple``.

    Uses the resolved grid's ``face_area`` (which returns per-cell transverse
    areas, so non-uniform steps within a single face are captured exactly) and
    falls back to ``spacing**2`` for a pure uniform grid with no resolved
    rectilinear grid. Shared by :class:`PoyntingFluxDetector` (single plane) and
    :class:`ClosedSurfacePoyntingFluxDetector` (box) so the grid/uniform handling
    lives in one place.

    Args:
        config (SimulationConfig): Simulation config providing the resolved grid.
        slice_tuple (SliceTuple3D): Grid slice of the detector region.
        axis (int): Normal axis of the face.
        dtype (jnp.dtype): Output dtype for the uniform fallback.

    Returns:
        jax.Array: Area weights broadcastable to the Poynting component on ``axis``.
    """
    grid = config.resolved_grid
    if grid is not None:
        return grid.face_area(axis=axis, slice_tuple=slice_tuple)
    spacing = config.uniform_spacing()
    shape = tuple(upper - lower for lower, upper in slice_tuple)
    return jnp.ones(shape, dtype=dtype) * spacing * spacing


def _slice_face(arr: jax.Array, axis: int, side: Literal["min", "max"]) -> jax.Array:
    """Slice ``arr`` to a size-one face along ``axis``, keeping the dimension.

    ``side="min"`` selects index 0 (the minimum face), ``side="max"`` the last
    index (the maximum face). Used both to reduce a full-box area-weight array to
    a single face and to pick the two boundary planes out of the phasor volume.
    """
    idxer: list[slice] = [slice(None)] * arr.ndim
    idxer[axis] = slice(0, 1) if side == "min" else slice(-1, None)
    return arr[tuple(idxer)]


def _phasor_poynting_vector(phasors: jax.Array) -> jax.Array:
    """Real Poynting vector ``Re(E x conj(H))`` from a phasor stack.

    Args:
        phasors (jax.Array): Complex phasors of shape ``(num_freqs, 6, *spatial)``
            with the six components ordered ``(Ex, Ey, Ez, Hx, Hy, Hz)`` on axis 1.

    Returns:
        jax.Array: Real Poynting vector of shape ``(num_freqs, 3, *spatial)``. The
        ``1/2`` time-average factor is *not* applied here; callers add it for the
        continuous scaling mode (see the detector ``compute_*`` methods).
    """
    E_ph, H_ph = phasors[:, :3], phasors[:, 3:]
    return compute_poynting_flux(E_ph, H_ph, axis=1).real


[docs] @autoinit class PoyntingFluxDetector(Detector): """Detector for measuring Poynting flux in electromagnetic simulations. This detector computes the Poynting flux (power flow) through a specified surface in the simulation volume. It can measure flux in either positive or negative direction along the propagation axis, and optionally reduce measurements to a single value by summing over the detection surface. """ #: Direction of flux measurement, either "+" for positive or "-" for negative along the propagation axis. direction: Literal["+", "-"] = frozen_field() #: If True, reduces measurements to a single value by summing over the detection surface. #: If False, maintains spatial distribution. Defaults to True. reduce_volume: bool = frozen_field(default=True) #: By default, the propagation axis for calculating the poynting #: flux is the axis, where the detector has a grid shape of 1. If the detector has a shape of 1 in more than #: one axes or a different axis should be used, then this attribute can/has to be set. Defaults to None. fixed_propagation_axis: int | None = frozen_field(default=None) #: By default, only the poynting flux component for the propagation axis #: is returned (scalar). If true, all three vector components are returned. Defaults to False. keep_all_components: bool = frozen_field(default=False) _cached_face_area_weights: jax.Array = private_field() # Poynting flux is positive. _signed_data: ClassVar[bool] = False @property def propagation_axis(self) -> int: """Determines the axis along which Poynting flux is measured. The propagation axis is identified as the dimension with size 1 in the detector's grid shape, representing a plane perpendicular to the flux measurement direction. Returns: int: Index of the propagation axis (0 for x, 1 for y, 2 for z) Raises: Exception: If detector shape does not have exactly one dimension of size 1 """ if self.fixed_propagation_axis is not None: if self.fixed_propagation_axis not in [0, 1, 2]: raise Exception(f"Invalid: {self.fixed_propagation_axis=}") return self.fixed_propagation_axis if sum([a == 1 for a in self.grid_shape]) != 1: raise Exception(f"Invalid poynting flux detector shape: {self.grid_shape}") return self.grid_shape.index(1)
[docs] def place_on_grid( self: Self, grid_slice_tuple: SliceTuple3D, config: SimulationConfig, key: jax.Array, ) -> Self: self = super().place_on_grid(grid_slice_tuple=grid_slice_tuple, config=config, key=key) can_determine_axis = self.keep_all_components or ( self.fixed_propagation_axis is not None or sum(a == 1 for a in self.grid_shape) == 1 ) if can_determine_axis: if self.keep_all_components: weights = jnp.stack( [ _resolve_face_area_weights(self._config, self.grid_slice_tuple, axis, self.dtype) for axis in range(3) ] ) elif self._config.resolved_grid is not None: # Only touch propagation_axis when a rectilinear grid needs it; the uniform # fallback is axis-independent, matching the legacy behavior where an invalid # fixed_propagation_axis does not raise until propagation_axis is accessed. weights = _resolve_face_area_weights( self._config, self.grid_slice_tuple, self.propagation_axis, self.dtype ) else: # Uniform fallback: area is spacing**2 for every cell regardless of axis. weights = _resolve_face_area_weights(self._config, self.grid_slice_tuple, 0, self.dtype) self = self.aset("_cached_face_area_weights", weights, create_new_ok=True) return self
def _shape_dtype_single_time_step( self, ) -> dict[str, jax.ShapeDtypeStruct]: if self.keep_all_components: shape = (3,) if self.reduce_volume else (3, *self.grid_shape) else: shape = (1,) if self.reduce_volume else self.grid_shape return {"poynting_flux": jax.ShapeDtypeStruct(shape, self.dtype)} def _face_area_weights(self) -> jax.Array: """Return face-area weights matching this detector's grid slice.""" return self._cached_face_area_weights
[docs] 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_permeability, inv_permittivity pf = compute_poynting_flux(E, H).real if not self.keep_all_components: pf = pf[self.propagation_axis] if self.direction == "-": pf = -pf if self.reduce_volume: pf = pf * self._face_area_weights() if self.keep_all_components: pf = pf.sum(axis=(1, 2, 3)) else: pf = pf.sum() arr_idx = self._time_step_to_arr_idx[time_step] new_full_arr = state["poynting_flux"].at[arr_idx].set(pf) new_state = {"poynting_flux": new_full_arr} return new_state
[docs] @autoinit class ClosedSurfacePoyntingFluxDetector(Detector): """Net Poynting flux through the closed surface of a rectangular box. Integrates the outward-normal Poynting component over all faces of the box spanned by this detector's grid slice, giving a single scalar net power. It is the natural probe for scattering/absorption power: a box in the pure scattered-field region (outside a TFSF box) yields the scattered power, while a box around an absorber yields the absorbed power (use ``orientation="inward"``). Unlike :class:`PoyntingFluxDetector`, which measures flux through a single plane, this closes the surface and sums all faces. The per-cell face-area weighting makes the surface integral exact on non-uniform grids, where cell areas differ across a single face. A face pair on an axis of size one cancels to zero, so the default ``axes`` (all axes with more than one cell) naturally reduces to a 4-face surface for a quasi-2D / periodic setup and a 6-face surface in full 3D. """ #: ``"outward"`` (default) counts net power leaving the box as positive. #: ``"inward"`` flips the sign (net power entering, e.g. absorbed power). orientation: Literal["outward", "inward"] = frozen_field(default="outward") #: Axes whose two faces contribute to the surface integral. ``None`` (default) #: uses every axis with a grid extent greater than one. axes: tuple[int, ...] | None = frozen_field(default=None) _face_area_weights_per_axis: tuple | None = private_field(default=None) # Net flux is signed (can be positive or negative). _signed_data: ClassVar[bool] = True def _resolve_active_axes(self) -> tuple[int, ...]: """Return the axes whose faces contribute (validated, size-one skipped by default).""" if self.axes is not None: return tuple(self.axes) return tuple(a for a in range(3) if self.grid_shape[a] > 1)
[docs] def place_on_grid( self: Self, grid_slice_tuple: SliceTuple3D, config: SimulationConfig, key: jax.Array, ) -> Self: if self.orientation not in ("outward", "inward"): raise ValueError(f"orientation must be 'outward' or 'inward', got {self.orientation!r}") if self.axes is not None and any(a not in (0, 1, 2) for a in self.axes): raise ValueError(f"axes entries must be in (0, 1, 2), got {self.axes}") self = super().place_on_grid(grid_slice_tuple=grid_slice_tuple, config=config, key=key) # Per-cell face-area weights for every axis (only the active ones are used # at record time). Computing all three keeps this a fixed-shape pytree. weights = tuple( _resolve_face_area_weights(self._config, self.grid_slice_tuple, axis, self.dtype) for axis in range(3) ) self = self.aset("_face_area_weights_per_axis", weights, create_new_ok=True) return self
def _shape_dtype_single_time_step( self, ) -> dict[str, jax.ShapeDtypeStruct]: return {"poynting_flux": jax.ShapeDtypeStruct((1,), self.dtype)}
[docs] 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_permeability, inv_permittivity if self._face_area_weights_per_axis is None: raise Exception("Detector is not yet placed on the grid") pf = compute_poynting_flux(E, H).real net = net_poynting_flux_through_box( poynting_vector=pf, active_axes=self._resolve_active_axes(), area_weights=self._face_area_weights_per_axis, ) if self.orientation == "inward": net = -net net = net.reshape((1,)).astype(self.dtype) arr_idx = self._time_step_to_arr_idx[time_step] new_full_arr = state["poynting_flux"].at[arr_idx].set(net) return {"poynting_flux": new_full_arr}
[docs] @autoinit class PhasorPoyntingFluxDetector(PhasorDetector): """Time-averaged Poynting flux through a single plane in the frequency domain. Frequency-domain analog of :class:`PoyntingFluxDetector`. Instead of recording the instantaneous flux at every time step, it accumulates the complex field phasors (inheriting all of :class:`PhasorDetector`'s DFT / subsampling / scaling machinery) and forms the time-averaged Poynting flux ``<S> = 1/2 Re(E(w) x H*(w))`` in the post-processing method :meth:`compute_poynting_flux`. This mirrors how :class:`ModeOverlapDetector` computes its (also bilinear) overlap after the run. Because the flux is a *product* of two independently accumulated DFTs, the surface integral cannot be folded into the per-step update the way the time-domain detector does -- the phasors must be complete first. All six field components are therefore always recorded. """ #: Direction of flux measurement, either "+" for positive or "-" for negative along the propagation axis. direction: Literal["+", "-"] = frozen_field() #: By default, the propagation axis is the axis where the detector has a grid shape of 1. If the detector has #: a shape of 1 in more than one axis or a different axis should be used, set this attribute. Defaults to None. fixed_propagation_axis: int | None = frozen_field(default=None) #: By default, only the Poynting flux component for the propagation axis is returned (scalar per wavelength). #: If True, all three vector components are returned. Defaults to False. keep_all_components: bool = frozen_field(default=False) #: Always all six field components -- both E and H are needed for the Poynting flux. Not user-configurable. components: Sequence[Literal["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"]] = frozen_field( default=("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"), init=False, ) #: Spatial phasors must be kept to perform the area integral, so volume reduction is disabled. reduce_volume: bool = frozen_field(default=False, init=False) #: Raw phasor auto-plotting is not meaningful; consume the ``compute_poynting_flux`` result instead. plot: bool = frozen_field(default=False, init=False) _cached_face_area_weights: jax.Array = private_field() # Poynting flux magnitude is non-negative, but the signed convention matches PoyntingFluxDetector. _signed_data: ClassVar[bool] = False @property def propagation_axis(self) -> int: """Axis along which the Poynting flux is measured (the size-one grid dimension).""" if self.fixed_propagation_axis is not None: if self.fixed_propagation_axis not in [0, 1, 2]: raise Exception(f"Invalid: {self.fixed_propagation_axis=}") return self.fixed_propagation_axis if sum([a == 1 for a in self.grid_shape]) != 1: raise Exception(f"Invalid poynting flux detector shape: {self.grid_shape}") return self.grid_shape.index(1)
[docs] def place_on_grid( self: Self, grid_slice_tuple: SliceTuple3D, config: SimulationConfig, key: jax.Array, ) -> Self: self = super().place_on_grid(grid_slice_tuple=grid_slice_tuple, config=config, key=key) real_dtype = jnp.float64 if self.dtype == jnp.complex128 else jnp.float32 if self.keep_all_components: weights = jnp.stack( [_resolve_face_area_weights(self._config, self.grid_slice_tuple, axis, real_dtype) for axis in range(3)] ) else: weights = _resolve_face_area_weights(self._config, self.grid_slice_tuple, self.propagation_axis, real_dtype) self = self.aset("_cached_face_area_weights", weights, create_new_ok=True) return self
[docs] def compute_poynting_flux(self, state: DetectorState) -> jax.Array: """Time-averaged Poynting flux through the plane at every recorded wavelength. Args: state (DetectorState): Detector state holding the accumulated phasors of shape ``(1, num_freqs, 6, *grid_shape)``. Returns: jax.Array: Real flux of shape ``(num_freqs,)`` (net power through the plane along the propagation axis), or ``(num_freqs, 3)`` when ``keep_all_components`` is set (all three Poynting components). """ phasors = state["phasor"][0] # (num_freqs, 6, *grid_shape) pv = _phasor_poynting_vector(phasors) # (num_freqs, 3, *grid_shape) if self.direction == "-": pv = -pv weights = self._cached_face_area_weights if self.keep_all_components: # weights: (3, *grid_shape); pv: (num_freqs, 3, *grid_shape) -> sum over spatial. flux = jnp.sum(pv * weights[None, ...], axis=(2, 3, 4)) else: # Only the propagation-axis component; weights broadcast over the frequency axis. comp = pv[:, self.propagation_axis] # (num_freqs, *grid_shape) flux = jnp.sum(comp * weights, axis=(1, 2, 3)) if self.scaling_mode == "continuous": flux = 0.5 * flux return flux
[docs] @autoinit class ClosedSurfacePhasorPoyntingFluxDetector(PhasorDetector): """Net time-averaged Poynting flux through a closed box surface (frequency domain). Frequency-domain analog of :class:`ClosedSurfacePoyntingFluxDetector`. It accumulates field phasors and forms the net time-averaged outward flux ``sum_faces 1/2 Re(E(w) x H*(w)) . dA`` in :meth:`compute_net_flux`. Only the **hollow shell** is recorded: for each active axis just the two boundary planes are stored, never the box interior. The persistent detector state is therefore ``O(surface)`` rather than ``O(volume)`` -- the interior phasors would be pure waste since the surface integral reads only the faces. A face pair on an axis of size one cancels, so the default ``axes`` (every axis with more than one cell) reduces to a 4-face surface for a quasi-2D setup and a 6-face surface in full 3D, exactly like the time-domain version. """ #: ``"outward"`` (default) counts net power leaving the box as positive. #: ``"inward"`` flips the sign (net power entering, e.g. absorbed power). orientation: Literal["outward", "inward"] = frozen_field(default="outward") #: Axes whose two faces contribute to the surface integral. ``None`` (default) #: uses every axis with a grid extent greater than one. axes: tuple[int, ...] | None = frozen_field(default=None) #: Always all six field components -- both E and H are needed for the Poynting flux. Not user-configurable. components: Sequence[Literal["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"]] = frozen_field( default=("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"), init=False, ) #: Storage is per-face and handled explicitly, so the base volume reduction is disabled. reduce_volume: bool = frozen_field(default=False, init=False) #: Raw phasor auto-plotting is not meaningful; consume the ``compute_net_flux`` result instead. plot: bool = frozen_field(default=False, init=False) #: Per-axis face-area weights, each already reduced to a single boundary plane (size one on the normal axis). _face_area_weights_per_axis: tuple | None = private_field(default=None) # Net flux is signed (can be positive or negative). _signed_data: ClassVar[bool] = True def _resolve_active_axes(self) -> tuple[int, ...]: """Return the axes whose faces contribute (validated, size-one skipped by default).""" if self.axes is not None: return tuple(self.axes) return tuple(a for a in range(3) if self.grid_shape[a] > 1)
[docs] def place_on_grid( self: Self, grid_slice_tuple: SliceTuple3D, config: SimulationConfig, key: jax.Array, ) -> Self: if self.orientation not in ("outward", "inward"): raise ValueError(f"orientation must be 'outward' or 'inward', got {self.orientation!r}") if self.axes is not None and any(a not in (0, 1, 2) for a in self.axes): raise ValueError(f"axes entries must be in (0, 1, 2), got {self.axes}") self = super().place_on_grid(grid_slice_tuple=grid_slice_tuple, config=config, key=key) real_dtype = jnp.float64 if self.dtype == jnp.complex128 else jnp.float32 # Reduce each axis' area weights to a single boundary plane (transverse area is # independent of position along the normal, so either face works). This matches the # hollow per-face storage below, where the field faces are already extracted. weights = tuple( _slice_face(_resolve_face_area_weights(self._config, self.grid_slice_tuple, axis, real_dtype), axis, "min") for axis in range(3) ) self = self.aset("_face_area_weights_per_axis", weights, create_new_ok=True) return self
def _shape_dtype_single_time_step( self, ) -> dict[str, jax.ShapeDtypeStruct]: field_dtype = jnp.complex128 if self.dtype == jnp.complex128 else jnp.complex64 num_components = len(self.components) num_frequencies = len(self._angular_frequencies) result: dict[str, jax.ShapeDtypeStruct] = {} for a in self._resolve_active_axes(): plane_shape = tuple(1 if i == a else self.grid_shape[i] for i in range(3)) shape = (num_frequencies, num_components, *plane_shape) result[f"phasor_axis{a}_min"] = jax.ShapeDtypeStruct(shape, field_dtype) result[f"phasor_axis{a}_max"] = jax.ShapeDtypeStruct(shape, field_dtype) return result
[docs] 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_permeability, inv_permittivity time_passed = time_step * self._config.time_step_duration static_scale = self._static_scale() EH = jnp.stack([E[0], E[1], E[2], H[0], H[1], H[2]], axis=0) # (6, nx, ny, nz) phase_angles = self._angular_frequencies * time_passed # (num_freqs,) phasors = jnp.exp(1j * phase_angles).reshape((len(self._angular_frequencies),) + (1,) * EH.ndim) new_phasors = EH * phasors * static_scale # (num_freqs, 6, nx, ny, nz) new_state = dict(state) for a in self._resolve_active_axes(): # Spatial axis a maps to array axis a + 2 (leading freq and component axes). for side in ("min", "max"): key = f"phasor_axis{a}_{side}" face = _slice_face(new_phasors, a + 2, side)[None, ...] # (1, num_freqs, 6, *plane) if self.inverse: new_state[key] = (state[key] - face).astype(self.dtype) else: new_state[key] = (state[key] + face).astype(self.dtype) return new_state
[docs] def compute_net_flux(self, state: DetectorState) -> jax.Array: """Net time-averaged Poynting flux through the closed surface at every wavelength. Args: state (DetectorState): Detector state holding the per-face phasors. Returns: jax.Array: Real net flux of shape ``(num_freqs,)``. Positive means net power leaving the box for ``orientation="outward"``. """ if self._face_area_weights_per_axis is None: raise Exception("Detector is not yet placed on the grid") active_axes = self._resolve_active_axes() num_freqs = len(self._angular_frequencies) real_dtype = jnp.float64 if self.dtype == jnp.complex128 else jnp.float32 net = jnp.zeros((num_freqs,), dtype=real_dtype) for a in active_axes: area = self._face_area_weights_per_axis[a] # (*plane) with normal axis size one for side, sign in (("max", 1.0), ("min", -1.0)): phasors = state[f"phasor_axis{a}_{side}"][0] # (num_freqs, 6, *plane) s_a = _phasor_poynting_vector(phasors)[:, a] # (num_freqs, *plane) net = net + sign * jnp.sum(s_a * area, axis=(1, 2, 3)) if self.orientation == "inward": net = -net if self.scaling_mode == "continuous": net = 0.5 * net return net