import math
from typing import Literal, Self, Sequence
import jax
import jax.numpy as jnp
from loguru import logger
from fdtdx.config import SimulationConfig
from fdtdx.core.jax.pytrees import autoinit, field, frozen_field, frozen_private_field
from fdtdx.core.wavelength import WaveCharacter
from fdtdx.objects.detectors.detector import Detector, DetectorState
from fdtdx.typing import SliceTuple3D
# Oversampling margin for the "auto" DFT subsampling stride: keep about this many samples per
# period of the highest recorded frequency.
_DFT_OVERSAMPLE = 12
[docs]
@autoinit
class PhasorDetector(Detector):
"""Detector for measuring frequency components of electromagnetic fields using an efficient Phasor Implementation.
This detector computes complex phasor representations of the field components at specified
frequencies, enabling frequency-domain analysis of the electromagnetic fields.
The amplitude and phase of the original phase can be reconstructed using jnp.abs(phasor) and jnp.angle(phasor).
The reconstruction itself can then be achieved using amplitude * jnp.cos(2 * jnp.pi * freq * t + phase).
"""
#: WaveCharacters to analyze.
wave_characters: Sequence[WaveCharacter] = field()
#: If True, reduces the volume of recorded data. Defaults to False.
reduce_volume: bool = frozen_field(default=False)
#: Sequence of field components to measure.
#: Can include any of: "Ex", "Ey", "Ez", "Hx", "Hy", "Hz".
components: Sequence[Literal["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"]] = frozen_field(
default=("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"),
)
dtype: jnp.dtype = frozen_field(
default=jnp.complex64,
)
#: Whether to plot the measured data. Defaults to False.
plot: bool = frozen_field(default=False)
#: Scaling of the resulting phasor. In continuous mode, the result is scaled by a factor of 2 / N, where N is
#: the number of time steps recorded. This allows accurate reconstruction of a continuous signal.
#: In pulse mode, the result is not scaled.
scaling_mode: Literal["continuous", "pulse"] = frozen_field(default="continuous")
#: Subsampling stride for the phasor DFT. Only every stride-th active time step is recorded,
#: with the kept samples rescaled to match every-step recording. If set to "auto", the stride
#: is derived from the highest recorded frequency and the time step duration. Defaults to 1,
#: which records every active time step.
dft_subsample: int | Literal["auto"] = frozen_field(default=1)
#: Concrete recording stride, resolved from dft_subsample at placement (1 = every active step).
_dft_stride: int = frozen_private_field(default=1)
def __post_init__(
self,
):
if self.dtype not in [jnp.complex64, jnp.complex128]:
raise Exception(f"Invalid dtype in PhasorDetector: {self.dtype}")
@property
def _angular_frequencies(self) -> jax.Array:
freqs = [wc.get_frequency() for wc in self.wave_characters]
return 2 * jnp.pi * jnp.array(freqs)
def _resolve_dft_stride(self) -> int:
"""Resolves dft_subsample to a concrete stride (>= 1). Requires the detector to be placed."""
sub = self.dft_subsample
if isinstance(sub, str):
if sub != "auto":
raise Exception(f"Invalid dft_subsample: {sub!r}")
dt = float(self._config.time_step_duration)
f_max = max((abs(float(wc.get_frequency())) for wc in self.wave_characters), default=0.0)
if f_max <= 0.0 or dt <= 0.0:
return 1
return max(1, math.floor(1.0 / (_DFT_OVERSAMPLE * f_max * dt)))
return max(1, int(sub))
def _calculate_on_list(self) -> list[bool]:
# Thin the base on-list to every stride-th active step; num_time_steps_recorded then
# reflects the kept count.
on_list = super()._calculate_on_list()
stride = self._resolve_dft_stride()
if stride <= 1:
return on_list
active = [t for t, on in enumerate(on_list) if on]
kept = [False] * len(on_list)
for t in active[::stride]:
kept[t] = True
return kept
[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)
stride = self._resolve_dft_stride()
if stride > 1:
# Warn about explicit strides close to the Nyquist limit ("auto" never trips this).
dt = float(self._config.time_step_duration)
f_max = max((abs(float(wc.get_frequency())) for wc in self.wave_characters), default=0.0)
if stride * dt * f_max > 0.25:
logger.warning(
f"Detector '{self.name}': dft_subsample={stride} leaves fewer than 4 samples per "
f"period of the highest recorded frequency ({f_max:.3e} Hz); the phasor may alias. "
'Reduce the stride or use dft_subsample="auto".'
)
# Store the concrete stride so update() reads a plain int (no host concretization under jit).
self = self.aset("_dft_stride", stride, create_new_ok=True)
return self
def _static_scale(self) -> float | int:
"""Computes the static scale factor for the configured scaling mode.
In continuous mode, the result is scaled by 2 / N with N the number of recorded time
steps. In pulse mode, each kept sample is weighted by the dft_subsample stride so that
subsampled recording matches the every-step DFT sum.
Returns:
float | int: Scale factor applied to each recorded sample.
"""
if self.scaling_mode == "continuous":
return 2 / self.num_time_steps_recorded
if self.scaling_mode == "pulse":
return self._dft_stride
raise Exception(f"Invalid scaling mode: {self.scaling_mode=}")
def _num_latent_time_steps(self) -> int:
return 1
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)
grid_shape = self.grid_shape if not self.reduce_volume else tuple([])
phasor_shape = (num_frequencies, num_components, *grid_shape)
return {"phasor": jax.ShapeDtypeStruct(shape=phasor_shape, dtype=field_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
time_passed = time_step * self._config.time_step_duration
static_scale = self._static_scale()
fields = []
if "Ex" in self.components:
fields.append(E[0])
if "Ey" in self.components:
fields.append(E[1])
if "Ez" in self.components:
fields.append(E[2])
if "Hx" in self.components:
fields.append(H[0])
if "Hy" in self.components:
fields.append(H[1])
if "Hz" in self.components:
fields.append(H[2])
EH = jnp.stack(fields, axis=0)
# Vectorized phasor calculation for all frequencies
phase_angles = self._angular_frequencies * time_passed # Shape: (num_freqs,)
phasors = jnp.exp(1j * phase_angles) # Shape: (num_freqs,)
# Reshape phasors to (num_freqs, 1, 1, 1, 1) for proper broadcasting with EH (num_components, x, y, z)
phasors = phasors.reshape((len(self._angular_frequencies),) + (1,) * EH.ndim)
new_phasors = EH * phasors * static_scale # Shape: (num_freqs, num_components, *grid_shape)
if self.reduce_volume:
# Average over spatial dimensions using physical cell volumes.
new_phasors = self._volume_weighted_spatial_mean(new_phasors, leading_dims=2)
if self.inverse:
result = state["phasor"] - new_phasors[None, ...]
else:
result = state["phasor"] + new_phasors[None, ...]
return {"phasor": result.astype(self.dtype)}