Source code for fdtdx.fdtd.symmetry

"""Automatic mirror-symmetry support for FDTD simulations.

When :attr:`fdtdx.SimulationConfig.symmetry` has a nonzero entry, :func:`fdtdx.place_objects`
reduces the domain to the symmetric half/quarter/octant before running the FDTD. This module
provides the two halves of that machinery:

* **Reduction** (used internally by ``place_objects``): clip every resolved grid slice onto the
  kept upper half along each symmetric axis, drop objects that fall entirely in the discarded
  half, insert a PEC/PMC wall on the symmetry plane, and forward the matching per-axis condition
  to mode sources/detectors.
* **Unfolding** (public helpers): reconstruct full-domain field and detector arrays from the
  reduced simulation, applying the correct per-component mirror parity.

Symmetry encoding (per axis, order ``(x, y, z)``): ``0`` = none, ``-1`` = PEC (electric wall),
``+1`` = PMC (magnetic wall). The symmetry plane is the geometric center of the axis; the upper
half is kept so the plane lands at the reduced domain's min edge — matching the mode solver's
"wall at the min edge" convention.
"""

from typing import Literal

import jax
import jax.numpy as jnp
from loguru import logger

from fdtdx.config import SimulationConfig
from fdtdx.core.misc import validate_symmetric_axis_cells
from fdtdx.core.null import Null
from fdtdx.fdtd.container import ArrayContainer, ObjectContainer
from fdtdx.objects.boundaries.bloch import BlochBoundary
from fdtdx.objects.boundaries.boundary import BaseBoundary
from fdtdx.objects.boundaries.pec import PerfectElectricConductor
from fdtdx.objects.boundaries.pmc import PerfectMagneticConductor
from fdtdx.objects.detectors.diffractive import DiffractiveDetector
from fdtdx.objects.detectors.energy import EnergyDetector
from fdtdx.objects.detectors.field import FieldDetector
from fdtdx.objects.detectors.mode import ModeOverlapDetector
from fdtdx.objects.detectors.phasor import PhasorDetector
from fdtdx.objects.detectors.poynting_flux import PoyntingFluxDetector
from fdtdx.objects.object import SimulationObject
from fdtdx.objects.sources.mode import ModePlaneSource
from fdtdx.typing import SliceTuple3D

#: Canonical order in which the field detectors stack their components (see e.g.
#: ``PhasorDetector.update``): ``Ex, Ey, Ez, Hx, Hy, Hz``. Each entry is ``(field_type, axis)``.
_COMPONENT_NAMES = ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz")
_COMPONENT_SPEC: tuple[tuple[Literal["E", "H"], int], ...] = (
    ("E", 0),
    ("E", 1),
    ("E", 2),
    ("H", 0),
    ("H", 1),
    ("H", 2),
)

_AXIS_NAMES = ("x", "y", "z")


# ──────────────────────────────────────────────────────────────────────────────
# Parity table
# ──────────────────────────────────────────────────────────────────────────────


def field_component_parity(
    field_type: Literal["E", "H"],
    component: int,
    axis: int,
    wall: int,
) -> int:
    """Mirror parity of a field component across a symmetry plane.

    For a mirror plane normal to ``axis`` with the kept domain on the ``+axis`` side, this is the
    sign that relates the field on the discarded side to its mirror image on the kept side. A PEC
    wall forces tangential ``E`` (and normal ``H``) to zero on the plane; a PMC wall forces
    tangential ``H`` (and normal ``E``) to zero.

    Args:
        field_type (Literal["E", "H"]): Which field the component belongs to.
        component (int): Component axis index (0=x, 1=y, 2=z).
        axis (int): Axis the mirror plane is normal to (0=x, 1=y, 2=z).
        wall (int): ``-1`` for a PEC (electric) wall, ``+1`` for a PMC (magnetic) wall.

    Returns:
        int: ``+1`` (even / unchanged under reflection) or ``-1`` (odd / sign-flipped).
    """
    normal = component == axis
    if wall == -1:  # PEC: tangential E = 0
        if field_type == "E":
            return 1 if normal else -1
        return -1 if normal else 1
    elif wall == 1:  # PMC: tangential H = 0
        if field_type == "E":
            return -1 if normal else 1
        return 1 if normal else -1
    raise ValueError(f"wall must be -1 (PEC) or +1 (PMC), got {wall}")


# ──────────────────────────────────────────────────────────────────────────────
# Reduction (used by place_objects)
# ──────────────────────────────────────────────────────────────────────────────


def reduce_resolved_slices(
    resolved_slices: dict[str, SliceTuple3D],
    object_map: dict[str, SimulationObject],
    config: SimulationConfig,
    volume_name: str,
) -> tuple[dict[str, SliceTuple3D], set[str], tuple[int, int, int]]:
    """Clip full-domain grid slices onto the symmetric (kept upper) half along each axis.

    Args:
        resolved_slices (dict[str, SliceTuple3D]): Full-domain grid slices, keyed by object name.
        object_map (dict[str, SimulationObject]): Object name → object, used for type-aware warnings.
        config (SimulationConfig): Simulation config; ``config.symmetry`` selects the axes.
        volume_name (str): Name of the simulation volume object.

    Returns:
        tuple: ``(new_slices, dropped_names, reduced_volume_shape)`` where ``new_slices`` excludes
        the dropped objects, ``dropped_names`` lists objects entirely in the discarded half, and
        ``reduced_volume_shape`` is the reduced volume grid shape ``(Nx', Ny', Nz')``.
    """
    symmetry = config.symmetry
    vol_slice = resolved_slices[volume_name]

    # Per-axis absolute index of the symmetry plane (= shift applied to map full → reduced),
    # and the reduced volume slice.
    mid_abs = [0, 0, 0]
    new_vol: list[tuple[int, int]] = []
    for a in range(3):
        vs0, vs1 = vol_slice[a]
        n = vs1 - vs0
        if symmetry[a] != 0:
            validate_symmetric_axis_cells(n, _AXIS_NAMES[a], subject="simulation volume")
            mid_abs[a] = vs0 + n // 2
            new_vol.append((0, vs1 - mid_abs[a]))
        else:
            mid_abs[a] = vs0
            new_vol.append((vs0, vs1))

    reduced_volume_shape = (
        new_vol[0][1] - new_vol[0][0],
        new_vol[1][1] - new_vol[1][0],
        new_vol[2][1] - new_vol[2][0],
    )

    new_slices: dict[str, SliceTuple3D] = {}
    dropped: set[str] = set()
    for name, sl in resolved_slices.items():
        if name == volume_name:
            new_slices[name] = (new_vol[0], new_vol[1], new_vol[2])
            continue

        obj = object_map[name]
        clipped: list[tuple[int, int]] = []
        drop = False
        for a in range(3):
            s0, s1 = sl[a]
            if symmetry[a] != 0:
                m = mid_abs[a]
                ns0 = max(s0, m) - m
                ns1 = min(s1, vol_slice[a][1]) - m
                if ns1 <= ns0:
                    drop = True
                elif s0 < m < s1 and (s0 + s1) != 2 * m:
                    logger.warning(
                        f"Object '{name}' straddles the {_AXIS_NAMES[a]}-symmetry plane "
                        f"asymmetrically (grid [{s0}, {s1}] about center {m}); the reduced "
                        f"simulation assumes mirror symmetry, so the discarded half is replaced by "
                        f"the mirror of the kept half."
                    )
                clipped.append((ns0, ns1))
            else:
                clipped.append((s0, s1))

        if drop:
            dropped.add(name)
            if isinstance(obj, BlochBoundary):
                logger.warning(
                    f"Dropped periodic/Bloch boundary '{name}' on a symmetry axis. Periodic "
                    f"boundaries and mirror symmetry are mutually exclusive on the same axis; use "
                    f"PML (or nothing) on the far side of a symmetric axis."
                )
            elif not isinstance(obj, BaseBoundary):
                logger.warning(
                    f"Object '{name}' lies entirely in the discarded (lower) half of a symmetric "
                    f"axis and was removed. Place it in the upper half or straddling the symmetry "
                    f"plane (the geometric center) so it survives the reduction."
                )
            continue

        new_slices[name] = (clipped[0], clipped[1], clipped[2])

    return new_slices, dropped, reduced_volume_shape


def make_symmetry_walls(
    config: SimulationConfig,
    reduced_volume_shape: tuple[int, int, int],
    key: jax.Array,
    existing_names: set[str],
) -> list[SimulationObject]:
    """Create the PEC/PMC wall objects sitting on each symmetry plane (reduced min edge).

    Args:
        config (SimulationConfig): Simulation config; ``config.symmetry`` selects the axes/types.
        reduced_volume_shape (tuple[int, int, int]): Reduced volume grid shape.
        key (jax.Array): JAX key for placement.
        existing_names (set[str]): Names already in use, so wall names can be made unique.

    Returns:
        list[SimulationObject]: PEC/PMC boundary objects, already placed on the reduced grid.
    """
    walls: list[SimulationObject] = []
    used = set(existing_names)
    for a in range(3):
        if config.symmetry[a] == 0:
            continue
        name = f"_sym_wall_{_AXIS_NAMES[a]}"
        suffix = 0
        while name in used:
            suffix += 1
            name = f"_sym_wall_{_AXIS_NAMES[a]}_{suffix}"
        used.add(name)

        partial_grid_shape: tuple[int | None, int | None, int | None] = (
            1 if a == 0 else None,
            1 if a == 1 else None,
            1 if a == 2 else None,
        )
        wall_cls = PerfectElectricConductor if config.symmetry[a] == -1 else PerfectMagneticConductor
        wall = wall_cls(
            name=name,
            axis=a,
            partial_grid_shape=partial_grid_shape,
            direction="-",
        )

        grid_slice: list[tuple[int, int]] = [(0, reduced_volume_shape[b]) for b in range(3)]
        grid_slice[a] = (0, 1)

        key, subkey = jax.random.split(key)
        wall = wall.place_on_grid(
            grid_slice_tuple=(grid_slice[0], grid_slice[1], grid_slice[2]),
            config=config,
            key=subkey,
        )
        walls.append(wall)
    return walls


def _derive_mode_symmetry(
    propagation_axis: int,
    reduced_slice: SliceTuple3D,
    symmetry: tuple[int, int, int],
) -> tuple[int, int]:
    """Derive the mode-solver symmetry 2-tuple for a plane perpendicular to ``propagation_axis``.

    The mode solver expects the two non-propagation axes in increasing index order, with ``0`` =
    PEC and ``1`` = PMC at the min edge. A wall is imposed only when the plane sits on the
    symmetry plane (its reduced slice starts at index 0 on that transverse axis).
    """
    transverse = [a for a in range(3) if a != propagation_axis]
    result = []
    for a in transverse:
        if symmetry[a] == 1 and reduced_slice[a][0] == 0:
            result.append(1)  # PMC
        else:
            result.append(0)  # PEC / electric wall (also the no-symmetry default)
    return (result[0], result[1])


def apply_mode_symmetry(
    placed_objects: list[SimulationObject],
    config: SimulationConfig,
) -> list[SimulationObject]:
    """Forward the simulation symmetry to mode sources/detectors left at their default symmetry.

    For each :class:`ModePlaneSource` / :class:`ModeOverlapDetector` whose ``symmetry`` is still
    the default ``(0, 0)``, set it to the value derived from the simulation symmetry and the
    object's reduced placement. Explicit user-set values are respected (a mismatch warns).

    Args:
        placed_objects (list[SimulationObject]): Objects already placed on the reduced grid.
        config (SimulationConfig): Simulation config.

    Returns:
        list[SimulationObject]: The objects, with mode symmetry tuples filled in where applicable.
    """
    result: list[SimulationObject] = []
    for obj in placed_objects:
        if isinstance(obj, (ModePlaneSource, ModeOverlapDetector)):
            shape = obj.grid_shape
            if 1 not in shape:
                result.append(obj)
                continue
            propagation_axis = shape.index(1)
            derived = _derive_mode_symmetry(propagation_axis, obj._grid_slice_tuple, config.symmetry)
            if obj.symmetry == (0, 0):
                obj = obj.aset("symmetry", derived)
            elif obj.symmetry != derived:
                logger.warning(
                    f"Mode object '{obj.name}' has an explicit symmetry={obj.symmetry} that differs "
                    f"from the value {derived} implied by config.symmetry={config.symmetry}. Keeping "
                    f"the explicit value; ensure this is intended."
                )
        result.append(obj)
    return result


# ──────────────────────────────────────────────────────────────────────────────
# Unfolding (public helpers)
# ──────────────────────────────────────────────────────────────────────────────


def _check_has_symmetry(symmetry: tuple[int, int, int]) -> None:
    if not any(s != 0 for s in symmetry):
        raise ValueError(
            "Nothing to unfold: this simulation has no symmetry (config.symmetry is (0, 0, 0)). "
            "The unfold helpers are only meaningful for symmetry-reduced simulations."
        )


[docs] def unfold_fields( field: jax.Array, symmetry: tuple[int, int, int], field_type: Literal["E", "H"], ) -> jax.Array: """Reconstruct a full-domain ``(3, Nx, Ny, Nz)`` field array from the reduced field. Mirrors the field across each symmetric axis with the correct per-component parity and concatenates the mirror image in front of the kept half, so the symmetry plane ends up at the center of the reconstructed array. Args: field (jax.Array): Reduced field, shape ``(3, Nx, Ny, Nz)`` (component axis first). symmetry (tuple[int, int, int]): Per-axis symmetry ``(x, y, z)``; see module docstring. field_type (Literal["E", "H"]): Whether ``field`` is the electric or magnetic field. Returns: jax.Array: Full-domain field; each symmetric axis is doubled in size. """ if field_type not in ("E", "H"): raise ValueError(f"field_type must be 'E' or 'H', got {field_type!r}") _check_has_symmetry(symmetry) arr = field for a in range(3): if symmetry[a] == 0: continue signs = jnp.asarray( [field_component_parity(field_type, c, a, symmetry[a]) for c in range(3)], dtype=arr.dtype, ).reshape((3, 1, 1, 1)) mirror = jnp.flip(arr, axis=a + 1) * signs arr = jnp.concatenate([mirror, arr], axis=a + 1) return arr
[docs] def unfold_source_mode( source, config: SimulationConfig, ) -> tuple[jax.Array, jax.Array]: """Reconstruct the full-domain ``(E, H)`` mode profile a mode source injects. A :class:`ModePlaneSource` solves and stores its mode on the *reduced* cross-section (``source._E`` / ``source._H``, real, shape ``(3, Nx, Ny, Nz)`` with a singleton on the propagation axis). This mirrors that profile back to the full transverse cross-section with the correct per-component parity. Only the two transverse axes are unfolded — the propagation axis is never a symmetry plane for a mode source (its mode plane is one cell thick there). Convenience wrapper so callers don't reach into the private ``_E`` / ``_H`` state; for the fields *recorded during the run*, prefer a detector on the source plane plus :func:`unfold_detector_states`. Args: source: A placed mode source whose ``apply`` has already run (so ``_E`` / ``_H`` are populated), e.g. a :class:`ModePlaneSource`. Run :func:`fdtdx.apply_params` first. config (SimulationConfig): Simulation config; must have nonzero ``symmetry`` on at least one axis transverse to the source's propagation axis. Returns: tuple[jax.Array, jax.Array]: Full-domain ``(E, H)`` mode profiles, shape ``(3, Nx, Ny, Nz)``. Raises: ValueError: If the mode has not been computed yet, the source is not a plane, or there is no transverse symmetry to unfold. """ mode_E = getattr(source, "_E", None) mode_H = getattr(source, "_H", None) if mode_E is None or mode_H is None or isinstance(mode_E, Null) or isinstance(mode_H, Null): raise ValueError( f"Source '{getattr(source, 'name', source)}' has no computed mode profile yet. Run " "fdtdx.apply_params (which invokes the source's mode solver) before unfolding." ) shape = source.grid_shape if 1 not in shape: raise ValueError( f"unfold_source_mode expects a plane source (one grid dimension of size 1); got grid shape {shape}." ) propagation_axis = shape.index(1) # Only the transverse axes are symmetry planes for a mode source; mask the propagation axis. transverse_symmetry: tuple[int, int, int] = ( 0 if propagation_axis == 0 else config.symmetry[0], 0 if propagation_axis == 1 else config.symmetry[1], 0 if propagation_axis == 2 else config.symmetry[2], ) if config.symmetry[propagation_axis] != 0: logger.warning( f"config.symmetry is nonzero on the source's propagation axis " f"{_AXIS_NAMES[propagation_axis]}; ignoring it when unfolding the mode profile (a mode " f"plane is never split along its propagation axis)." ) if not any(s != 0 for s in transverse_symmetry): raise ValueError( "No transverse symmetry to unfold for this source plane: config.symmetry is zero on both " "axes transverse to the propagation axis." ) return unfold_fields(mode_E, transverse_symmetry, "E"), unfold_fields(mode_H, transverse_symmetry, "H")
[docs] def unfold_array( arr: jax.Array, symmetry: tuple[int, int, int], spatial_axes: tuple[int, int, int], signs: dict[int, jax.Array] | None = None, ) -> jax.Array: """Mirror-and-concatenate a spatial array along each symmetric axis. Generic building block used by :func:`unfold_detector_states`. For every axis ``a`` with ``symmetry[a] != 0`` the array is flipped along its corresponding array axis ``spatial_axes[a]`` (optionally multiplied by a broadcastable per-component sign), and the mirror image is concatenated in front of the original. Args: arr (jax.Array): Array to unfold. symmetry (tuple[int, int, int]): Per-axis symmetry ``(x, y, z)``; ``0`` axes are skipped. spatial_axes (tuple[int, int, int]): For each physical axis, the corresponding array axis. signs (dict[int, jax.Array] | None): Optional mapping physical-axis → broadcastable sign array applied to the mirror image (defaults to ``+1``). Returns: jax.Array: The unfolded array. """ _check_has_symmetry(symmetry) for a in range(3): if symmetry[a] == 0: continue ax = spatial_axes[a] sign = 1.0 if signs is None else signs.get(a, 1.0) mirror = jnp.flip(arr, axis=ax) * sign arr = jnp.concatenate([mirror, arr], axis=ax) return arr
def _stored_component_spec(components) -> list[tuple[Literal["E", "H"], int]]: """Stored ``(field_type, component_axis)`` order for a Field/Phasor detector's components.""" return [_COMPONENT_SPEC[i] for i, name in enumerate(_COMPONENT_NAMES) if name in components] def _component_signs( spec: list[tuple[Literal["E", "H"], int]], touched: tuple[int, int, int], ndim: int, component_axis: int, ) -> dict[int, jax.Array]: """Per-axis, per-component mirror signs broadcastable against a detector array.""" out: dict[int, jax.Array] = {} for a in range(3): if touched[a] == 0: continue vals = [field_component_parity(ft, ca, a, touched[a]) for (ft, ca) in spec] shape = [1] * ndim shape[component_axis] = len(vals) out[a] = jnp.asarray(vals).reshape(shape) return out def _reduce_factor( parities: list[list[int]], ndim: int, component_axis: int, mean: bool, ) -> jax.Array: """Per-component scale factor that turns a half-domain reduction into the full-domain one. For a linear reduction over the detector region, the full-domain value relates to the stored half-domain value by ``∏_axes (1 + parity)`` for a *sum* and ``∏_axes (1 + parity) / 2`` for a *mean*: an even component doubles (sum) or is unchanged (mean), an odd component vanishes. Args: parities (list[list[int]]): ``parities[c]`` is the per-touched-axis parity list of component ``c``. ndim (int): Rank of the stored array (so the factor broadcasts against it). component_axis (int): Array axis holding the components. mean (bool): True if the detector reduces by averaging, False if by summing. Returns: jax.Array: Factor of shape ``[1, ..., num_components, ..., 1]`` (component axis populated). """ factors = [] for per_axis in parities: f = 1.0 for p in per_axis: f *= (1 + p) / 2 if mean else (1 + p) factors.append(f) shape = [1] * ndim shape[component_axis] = len(factors) return jnp.asarray(factors).reshape(shape) def _poynting_parity(component: int, axis: int, wall: int) -> int: """Mirror parity of the Poynting component ``S_component = (E x H)_component``. ``S_i`` involves the two field components transverse to ``i``, so its parity is the product of the corresponding ``E`` and ``H`` parities. """ j, k = (x for x in range(3) if x != component) return field_component_parity("E", j, axis, wall) * field_component_parity("H", k, axis, wall) def _unfold_poynting(detector, state: dict[str, jax.Array], touched: tuple[int, int, int]): """Unfold a PoyntingFluxDetector (scalar/vector, summed/spatial) via per-component flux parity.""" arr = state["poynting_flux"] components = (0, 1, 2) if detector.keep_all_components else (detector.propagation_axis,) touched_axes = [a for a in range(3) if touched[a] != 0] if detector.reduce_volume: # summed flux -> per-component factor ∏ (1 + parity) parities = [[_poynting_parity(i, a, touched[a]) for a in touched_axes] for i in components] if detector.keep_all_components: factor = _reduce_factor(parities, ndim=arr.ndim, component_axis=1, mean=False) # (T, 3) return {"poynting_flux": arr * factor} scalar = 1.0 for p in parities[0]: scalar *= 1 + p return {"poynting_flux": arr * scalar} # Spatial flux density -> mirror with the flux-component parity. if detector.keep_all_components: # (T, 3, nx, ny, nz) signs = { a: jnp.asarray([_poynting_parity(i, a, touched[a]) for i in components]).reshape((1, 3, 1, 1, 1)) for a in touched_axes } return {"poynting_flux": unfold_array(arr, touched, (2, 3, 4), signs)} # (T, nx, ny, nz): single component along the propagation axis. p = detector.propagation_axis signs = {a: jnp.asarray(float(_poynting_parity(p, a, touched[a]))) for a in touched_axes} return {"poynting_flux": unfold_array(arr, touched, (1, 2, 3), signs)} def _unfold_energy_slices(state: dict[str, jax.Array], touched: tuple[int, int, int]): """Unfold an EnergyDetector(as_slices=True): mirror each 2D plane along its in-plane symmetric axes. Energy density is even, so the parity is ``+1``; the collapsed-axis mean is already the full-domain value (energy is symmetric about the plane).""" # Each plane stores (T, axis_a, axis_b); map those two physical axes to array axes 1 and 2. planes = { "XY Plane": (0, 1), "XZ Plane": (0, 2), "YZ Plane": (1, 2), } out: dict[str, jax.Array] = {} for key, phys_axes in planes.items(): arr = state[key] sub = [0, 0, 0] spatial_axes = [0, 0, 0] for arr_axis, phys in enumerate(phys_axes, start=1): sub[phys] = touched[phys] spatial_axes[phys] = arr_axis if any(s != 0 for s in sub): out[key] = unfold_array(arr, (sub[0], sub[1], sub[2]), (spatial_axes[0], spatial_axes[1], spatial_axes[2])) else: out[key] = arr return out def _unfold_one_detector( detector, state: dict[str, jax.Array], touched: tuple[int, int, int], count: int, ) -> dict[str, jax.Array]: """Unfold a single detector's recorded state to the full domain. See module docstring. Everything except :class:`DiffractiveDetector` is recovered purely from the stored reduced output plus the parity table: spatial outputs are mirrored per component, and ``reduce_volume`` sums/means are rescaled per component (even doubles/keeps, odd vanishes). """ # DiffractiveDetector first: it subclasses Detector (not PhasorDetector) and stores per-order # efficiencies from an FFT over the plane. Halving the domain changes the diffraction-order # basis, so the full-domain orders are not a function of the reduced-domain ones — unrecoverable # post-hoc from the stored output. if isinstance(detector, DiffractiveDetector): raise NotImplementedError( "unfold_detector_states cannot unfold a DiffractiveDetector: its diffraction-order basis " "depends on the (reduced) domain size, so the full-domain orders are not recoverable from " "the stored efficiencies. Unfold the fields with unfold_fields and recompute the orders." ) # PhasorDetector (and ModeOverlapDetector, which subclasses it). Note: for ModeOverlapDetector # the mode-overlap S-parameter is already correct on the reduced domain (source and detector # share the reduced plane); unfolding only affects the raw stored phasor field. if isinstance(detector, PhasorDetector): spec = _stored_component_spec(detector.components) if detector.reduce_volume: # (1, num_freqs, num_components); mean reduction arr = state["phasor"] parities = [ [field_component_parity(ft, ca, a, touched[a]) for a in range(3) if touched[a]] for ft, ca in spec ] factor = _reduce_factor(parities, ndim=arr.ndim, component_axis=2, mean=True) return {"phasor": arr * factor.astype(arr.dtype)} arr = state["phasor"] # (1, num_freqs, num_components, nx, ny, nz) signs = _component_signs(spec, touched, arr.ndim, component_axis=2) return {"phasor": unfold_array(arr, touched, (3, 4, 5), signs)} if isinstance(detector, FieldDetector): spec = _stored_component_spec(detector.components) if detector.reduce_volume: # (T, num_components); mean reduction arr = state["fields"] parities = [ [field_component_parity(ft, ca, a, touched[a]) for a in range(3) if touched[a]] for ft, ca in spec ] factor = _reduce_factor(parities, ndim=arr.ndim, component_axis=1, mean=True) return {"fields": arr * factor.astype(arr.dtype)} arr = state["fields"] # (T, num_components, nx, ny, nz) signs = _component_signs(spec, touched, arr.ndim, component_axis=1) return {"fields": unfold_array(arr, touched, (2, 3, 4), signs)} if isinstance(detector, EnergyDetector): if detector.as_slices: return _unfold_energy_slices(state, touched) if detector.reduce_volume: # summed energy; energy density is even -> x2 per plane return {"energy": state["energy"] * (2**count)} arr = state["energy"] # (T, nx, ny, nz) return {"energy": unfold_array(arr, touched, (1, 2, 3))} if isinstance(detector, PoyntingFluxDetector): return _unfold_poynting(detector, state, touched) raise NotImplementedError( f"unfold_detector_states does not know how to unfold detector type " f"{type(detector).__name__!r}. Unfold the fields with unfold_fields instead." )
[docs] def unfold_detector_states( arrays: ArrayContainer, objects: ObjectContainer, config: SimulationConfig, ) -> ArrayContainer: """Reconstruct full-domain detector states from a symmetry-reduced simulation. This is a pure post-processing step: it transforms each detector's stored reduced-domain output into the full-domain result using the parity table, with no work added to the FDTD time loop. Each detector that sits on one or more symmetry planes is unfolded per its type — spatial outputs are mirrored with the correct per-component parity; ``reduce_volume`` sums/means are rescaled per component (even components double/keep, odd components vanish); ``as_slices`` energy planes are mirrored along their in-plane symmetric axes. Detectors that do not touch any symmetry plane are returned unchanged. All detector types are supported except :class:`DiffractiveDetector`, whose diffraction-order basis depends on the domain size and so cannot be recovered from the stored efficiencies; for that, unfold the fields with :func:`unfold_fields` and recompute. Args: arrays (ArrayContainer): Arrays returned by the reduced simulation. objects (ObjectContainer): The placed (reduced) objects. config (SimulationConfig): Simulation config (must have nonzero ``symmetry``). Returns: ArrayContainer: A copy of ``arrays`` with ``detector_states`` reconstructed to full domain. Raises: ValueError: If ``config.symmetry`` is ``(0, 0, 0)`` (nothing to unfold). NotImplementedError: For a :class:`DiffractiveDetector` (see above). """ _check_has_symmetry(config.symmetry) detector_by_name = {d.name: d for d in objects.detectors} new_states: dict[str, dict[str, jax.Array]] = {} for name, state in arrays.detector_states.items(): detector = detector_by_name.get(name) if detector is None: new_states[name] = state continue det_slice = detector._grid_slice_tuple # Symmetric axes whose reduced slice starts at the symmetry plane (min edge, index 0). touched: tuple[int, int, int] = ( config.symmetry[0] if (config.symmetry[0] != 0 and det_slice[0][0] == 0) else 0, config.symmetry[1] if (config.symmetry[1] != 0 and det_slice[1][0] == 0) else 0, config.symmetry[2] if (config.symmetry[2] != 0 and det_slice[2][0] == 0) else 0, ) count = sum(1 for a in range(3) if touched[a] != 0) if count == 0: new_states[name] = state continue new_states[name] = _unfold_one_detector(detector, state, touched, count) return arrays.aset("detector_states", new_states)