"""Differentiable resonance extraction from short time-domain ringdowns.
The routines in this module deliberately operate only on JAX arrays. A scalar
objective constructed from :func:`fit_resonance` can therefore be differentiated
through an FDTD detector trace and back to material or geometry parameters.
The current estimator targets one isolated, already-localized resonance. It
coherently demodulates equal translated windows and fits the complex ratio
between adjacent windows. Translating a window over a single damped mode
``E(t) = Re[a exp((-decay_rate + 2j*pi*frequency) t)]``
multiplies its demodulated amplitude by one complex number. Fitting that number
is substantially better conditioned in float32 than fitting the nearly-unit
per-timestep pole of an optical-frequency FDTD signal.
"""
from __future__ import annotations
import math
from typing import NamedTuple
import jax
import jax.numpy as jnp
from fdtdx.constants import c as C_0
class ResonanceEstimate(NamedTuple):
"""JAX-array outputs of a differentiable single-resonance fit.
Attributes:
frequency: Resonant frequency in Hz.
decay_rate: Field-amplitude decay rate in rad/s (equivalently 1/s).
quality_factor: ``pi * frequency / decay_rate``.
pole: Complex translation factor between adjacent coherent windows.
residual: Relative RMS residual of the complex exponential regression.
window_consistency: Relative RMS spread of adjacent-window decay rates.
amplitudes: Complex coherently demodulated amplitude in every window.
"""
frequency: jax.Array
decay_rate: jax.Array
quality_factor: jax.Array
pole: jax.Array
residual: jax.Array
window_consistency: jax.Array
amplitudes: jax.Array
class EnergyDecayEstimate(NamedTuple):
"""JAX-array outputs of a differentiable stored-energy decay fit.
Unlike :class:`ResonanceEstimate`, this estimator does not infer a
frequency. It fits the logarithm of equal-window mean electromagnetic
energies to one exponential and converts the resulting *energy* decay
rate to ``Q = 2*pi*frequency / decay_rate``.
"""
decay_rate: jax.Array
quality_factor: jax.Array
residual: jax.Array
window_consistency: jax.Array
window_energies: jax.Array
class RingdownAcquisition(NamedTuple):
"""Differentiable, fit-free measures of a time-domain ringdown.
These measures are intended for the acquisition phase of an inverse-design
run, before a sufficiently isolated pole exists for :func:`fit_resonance`.
The constant component is removed before measuring the signal; without
that operation a small Yee-grid DC offset can dominate a weak optical
ringdown and provide a misleading optimization gradient.
"""
early_rms: jax.Array
late_rms: jax.Array
retention: jax.Array
score: jax.Array
class CoherentRingdownAcquisition(NamedTuple):
"""Target-frequency measures for acquiring an atom-coupled resonance.
Unlike broadband RMS, these amplitudes are coherently demodulated at one
specified frequency. They therefore reject late DC, grid transients, and
modes outside the atomic linewidth while remaining differentiable.
"""
early_amplitude: jax.Array
late_amplitude: jax.Array
retention: jax.Array
late_coherence: jax.Array
score: jax.Array
class CavityQEDMetrics(NamedTuple):
"""Differentiable mode-volume and Purcell estimates in stable log form."""
mode_volume: jax.Array
purcell_factor: jax.Array
log_mode_volume: jax.Array
log_purcell_factor: jax.Array
def predict_continuation_frequency(
previous_frequency: jax.Array | float,
current_frequency: jax.Array | float,
*,
branch_spacing: jax.Array | float,
step_ratio: jax.Array | float = 1.0,
maximum_prediction_fraction: float = 0.25,
) -> jax.Array:
"""Predict the next point on one continuous resonance branch.
An isolated Maxwell eigenvalue is locally analytic under a smooth material
continuation (the usual Kato/Rellich eigenvalue-continuation result). A
secant is therefore a first-order predictor for the next pole. Clipping
that prediction inside a fixed fraction of the translated-window branch
spacing prevents a noisy previous step from jumping to a neighboring
``1 / window_duration`` alias.
This helper only operates on already measured scalar frequencies. It adds
no FDTD solve, and its result is stop-gradient because selecting a pole
branch is a discrete controller decision that must remain frozen during a
forward/adjoint pair and all of its trust-region replays.
"""
if not 0.0 < maximum_prediction_fraction < 0.5:
raise ValueError("maximum_prediction_fraction must lie strictly inside (0, 0.5)")
previous = jnp.asarray(previous_frequency)
current = jnp.asarray(current_frequency)
spacing = jnp.asarray(branch_spacing, dtype=current.dtype)
ratio = jnp.asarray(step_ratio, dtype=current.dtype)
limit = jnp.asarray(maximum_prediction_fraction, dtype=current.dtype) * spacing
trend = ratio * (current - previous)
predicted = current + jnp.clip(trend, -limit, limit)
valid = (
jnp.isfinite(previous)
& jnp.isfinite(current)
& jnp.isfinite(spacing)
& jnp.isfinite(ratio)
& (previous > 0.0)
& (current > 0.0)
& (spacing > 0.0)
& (ratio >= 0.0)
)
predicted = jnp.where(valid, predicted, current)
return jax.lax.stop_gradient(predicted)
def dominant_frequency_branch(
signal: jax.Array,
*,
time_step: float | jax.Array,
center_frequency: float | jax.Array,
samples_per_window: int,
start_index: int = 0,
num_windows: int = 3,
maximum_branch_offset: int = 4,
) -> jax.Array:
"""Select the dominant translated-window frequency branch.
A pole inferred from the phase between windows is ambiguous modulo
``1 / window_duration``. A coarse periodogram over all fit windows has
finer spacing by ``num_windows`` and therefore selects the branch before
:func:`fit_resonance` performs its precise complex-pole regression. The
discrete branch is intentionally stop-gradient; derivatives flow through
the subsequent fixed-branch resonance fit.
"""
signal = jnp.asarray(signal)
if signal.ndim != 1:
raise ValueError(f"signal must be one-dimensional, got shape {signal.shape}")
if samples_per_window < 2:
raise ValueError("samples_per_window must be at least 2")
if num_windows < 3:
raise ValueError("at least three windows are required")
if maximum_branch_offset < 0:
raise ValueError("maximum_branch_offset must be nonnegative")
sample_count = samples_per_window * num_windows
if start_index < 0 or start_index + sample_count > signal.shape[0]:
raise ValueError("requested branch-search windows do not fit in signal")
real_dtype = jnp.result_type(signal.dtype, jnp.float32)
samples = signal[start_index : start_index + sample_count].astype(real_dtype)
window = jnp.hanning(sample_count + 2).astype(real_dtype)[1:-1]
spectrum = jnp.abs(jnp.fft.rfft(samples * window))
dt = jnp.asarray(time_step, dtype=real_dtype)
frequencies = jnp.arange(spectrum.shape[0], dtype=real_dtype) / (dt * jnp.asarray(sample_count, dtype=real_dtype))
branch_spacing = 1.0 / (dt * jnp.asarray(samples_per_window, dtype=real_dtype))
half_width = (maximum_branch_offset + 0.5) * branch_spacing
center = jnp.asarray(center_frequency, dtype=real_dtype)
in_band = jnp.abs(frequencies - center) <= half_width
scores = jnp.where(in_band, spectrum, -jnp.inf)
return jax.lax.stop_gradient(frequencies[jnp.argmax(scores)])
def late_time_frequency_branch(
signal: jax.Array,
*,
time_step: float | jax.Array,
frequency_window: tuple[float, float],
samples_per_window: int,
start_index: int = 0,
num_windows: int | None = None,
) -> jax.Array:
"""Select the observable long-lived branch from the final fit window.
A broadband pulse can leave a large, rapidly decaying resonance near the
beginning of a ringdown and a much smaller high-Q resonance at late time.
Selecting a frequency from the complete trace then biases a single-pole
regression toward the prompt mode. This helper removes local DC, applies
a Hann window to the *last* complete fit window, and returns its strongest
positive-frequency Fourier bin inside ``frequency_window``. The existing
:func:`fit_resonance` regression subsequently refines the bin to sub-bin
frequency and decay-rate accuracy.
The returned branch is explicitly stop-gradient. Pole discovery is a
discrete choice: run this helper on an accepted checkpoint trace, store
the resulting scalar frequency, and keep it fixed throughout the next
optimizer epoch or line search. Derivatives through :func:`fit_resonance`
remain ordinary exact JAX derivatives on that fixed branch. Re-selecting
the branch for every trial can still make the *forward* objective
discontinuous even though no derivative passes through the selection.
``frequency_window`` should be a fixed physical/source bandwidth, not a
per-structure hand-tuned interval. A peak returned from noise is rejected
by the residual, window-consistency, time-prefix, and energy checks applied
to the subsequent resonance fit.
Args:
signal: One-dimensional real detector trace containing the ringdown.
time_step: Finite positive scalar time between adjacent samples in
seconds. It is a fixed acquisition setting rather than a traced
branch-selection input.
frequency_window: Inclusive positive-frequency search interval in Hz,
ordered ``(minimum, maximum)``.
samples_per_window: Samples in each translated fit window. Using the
same value in this selector and :func:`fit_resonance` guarantees
that the selected Fourier bin lies within the fit's unambiguous
phase-refinement interval.
start_index: First ringdown sample, after the source has cleared.
num_windows: Number of complete windows in the intended pole fit. By
default, use every complete window after ``start_index``.
Returns:
A scalar JAX array containing the selected frequency in Hz. If the
interval contains no finite bin or the final window has no numerically
observable oscillation, the result is ``nan`` so downstream trust
checks cannot accidentally certify it.
Raises:
ValueError: If the signal, frequency interval, or static window
configuration is invalid.
"""
signal = jnp.asarray(signal)
if signal.ndim != 1:
raise ValueError(f"signal must be one-dimensional, got shape {signal.shape}")
if jnp.iscomplexobj(signal):
raise ValueError("signal must be real-valued")
if samples_per_window < 2:
raise ValueError("samples_per_window must be at least 2")
if start_index < 0 or start_index >= signal.shape[0]:
raise ValueError(f"start_index must be in [0, {signal.shape[0]}), got {start_index}")
time_step_array = jnp.asarray(time_step)
if time_step_array.ndim != 0:
raise ValueError(f"time_step must be a scalar, got shape {time_step_array.shape}")
try:
time_step_value = float(time_step)
except (TypeError, ValueError, jax.errors.ConcretizationTypeError) as exc:
raise ValueError("time_step must be a concrete finite positive scalar") from exc
if not math.isfinite(time_step_value) or time_step_value <= 0.0:
raise ValueError("time_step must be finite and strictly positive")
if len(frequency_window) != 2:
raise ValueError("frequency_window must contain exactly two values")
minimum_frequency, maximum_frequency = (float(value) for value in frequency_window)
if (
not math.isfinite(minimum_frequency)
or not math.isfinite(maximum_frequency)
or minimum_frequency <= 0.0
or maximum_frequency <= minimum_frequency
):
raise ValueError("frequency_window must be finite, positive, and strictly increasing")
available_windows = (signal.shape[0] - start_index) // samples_per_window
if num_windows is None:
num_windows = available_windows
if num_windows < 3:
raise ValueError("at least three complete coherent windows are required")
if num_windows > available_windows:
raise ValueError(f"requested {num_windows} windows but only {available_windows} fit in the signal")
last_start = start_index + (num_windows - 1) * samples_per_window
real_dtype = jnp.result_type(signal.dtype, jnp.float32)
raw_samples = signal[last_start : last_start + samples_per_window].astype(real_dtype)
samples_are_finite = jnp.all(jnp.isfinite(raw_samples))
raw_scale = jnp.max(jnp.abs(raw_samples))
samples = raw_samples - jnp.mean(raw_samples)
oscillating_scale = jnp.max(jnp.abs(samples))
window = jnp.hanning(samples_per_window + 2).astype(real_dtype)[1:-1]
windowed_samples = samples * window
spectrum = jnp.abs(jnp.fft.rfft(windowed_samples))
dt = jnp.asarray(time_step_value, dtype=real_dtype)
frequencies = jnp.arange(spectrum.shape[0], dtype=real_dtype) / (
dt * jnp.asarray(samples_per_window, dtype=real_dtype)
)
in_band = (frequencies >= minimum_frequency) & (frequencies <= maximum_frequency)
valid_bins = in_band & jnp.isfinite(spectrum)
scores = jnp.where(valid_bins, spectrum, -jnp.inf)
peak = jnp.max(scores)
selected = frequencies[jnp.argmax(scores)]
tiny = jnp.asarray(jnp.finfo(real_dtype).tiny, dtype=real_dtype)
eps = jnp.asarray(jnp.finfo(real_dtype).eps, dtype=real_dtype)
time_domain_floor = jnp.maximum(tiny, eps * raw_scale)
spectral_floor = jnp.maximum(tiny, eps * jnp.sum(jnp.abs(windowed_samples)))
observable = (
samples_are_finite
& jnp.any(valid_bins)
& jnp.isfinite(peak)
& (oscillating_scale > time_domain_floor)
& (peak > spectral_floor)
)
selected = jnp.where(observable, selected, jnp.asarray(jnp.nan, dtype=real_dtype))
return jax.lax.stop_gradient(selected)
def windowed_mode_field_rms(
demodulated_amplitude: jax.Array,
*,
decay_rate: float | jax.Array,
time_step: float | jax.Array,
samples_per_window: int,
) -> jax.Array:
"""Convert a real-signal Hann demodulate to same-window modal RMS.
Demodulating ``A cos(omega t + phi)`` returns ``A / 2``, not the peak
amplitude. The additional decay-envelope factor maps the Hann-weighted
coherent amplitude onto the rectangular mean-square window used by
:func:`fit_energy_decay`, so stored energy and local field refer to the
same interval.
"""
if samples_per_window < 2:
raise ValueError("samples_per_window must be at least 2")
amplitude = jnp.asarray(demodulated_amplitude)
real_dtype = jnp.result_type(jnp.real(amplitude).dtype, jnp.float32)
gamma = jnp.asarray(decay_rate, dtype=real_dtype)
dt = jnp.asarray(time_step, dtype=real_dtype)
index = jnp.arange(samples_per_window, dtype=real_dtype)
envelope = jnp.exp(-gamma * dt * index)
window = jnp.hanning(samples_per_window + 2).astype(real_dtype)[1:-1]
coherent_envelope = jnp.sum(window * envelope) / jnp.sum(window)
rms_envelope = jnp.sqrt(jnp.mean(jnp.square(envelope)))
tiny = jnp.asarray(jnp.finfo(real_dtype).tiny, dtype=real_dtype)
return (
jnp.sqrt(jnp.asarray(2.0, dtype=real_dtype))
* jnp.abs(amplitude)
* rms_envelope
/ jnp.maximum(coherent_envelope, tiny)
)
def cavity_qed_metrics(
*,
quality_factor: jax.Array,
frequency: jax.Array,
local_field_rms: jax.Array,
modal_energy: jax.Array,
symmetry_factor: float | jax.Array = 1.0,
length_unit: float | jax.Array = 1e-6,
) -> CavityQEDMetrics:
"""Estimate effective mode volume and Purcell factor without unstable ratios.
For a late-time single-mode ringdown, total time-averaged electromagnetic
energy divided by the squared local-field RMS is the electric mode volume.
The computation stays in log space until its returned physical metrics are
formed. This matters in float32 FDTD: differentiating the algebraically
equivalent nested ratio ``U / E**2`` can overflow even when its value and
true derivative are finite.
``length_unit`` is metres per desired mode-volume length unit; its default
returns mode volume in cubic micrometres. ``symmetry_factor`` restores the
multiplicity of an energy integral taken on a reduced simulation domain.
"""
dtype = jnp.result_type(
quality_factor,
frequency,
local_field_rms,
modal_energy,
jnp.float32,
)
tiny = jnp.asarray(jnp.finfo(dtype).tiny, dtype=dtype)
quality_factor = jnp.maximum(jnp.asarray(quality_factor, dtype=dtype), tiny)
frequency = jnp.maximum(jnp.asarray(frequency, dtype=dtype), tiny)
local_field_rms = jnp.maximum(jnp.asarray(local_field_rms, dtype=dtype), jnp.sqrt(tiny))
modal_energy = jnp.maximum(jnp.asarray(modal_energy, dtype=dtype), tiny)
symmetry_factor = jnp.maximum(jnp.asarray(symmetry_factor, dtype=dtype), tiny)
length_unit = jnp.maximum(jnp.asarray(length_unit, dtype=dtype), tiny)
log_mode_volume = (
jnp.log(symmetry_factor) + jnp.log(modal_energy) - 2.0 * jnp.log(local_field_rms) - 3.0 * jnp.log(length_unit)
)
wavelength_units = C_0 / frequency / length_unit
log_purcell_factor = (
jnp.log(jnp.asarray(3.0 / (4.0 * jnp.pi**2), dtype=dtype))
+ 3.0 * jnp.log(wavelength_units)
+ jnp.log(quality_factor)
- log_mode_volume
)
return CavityQEDMetrics(
mode_volume=jnp.exp(log_mode_volume),
purcell_factor=jnp.exp(log_purcell_factor),
log_mode_volume=log_mode_volume,
log_purcell_factor=log_purcell_factor,
)
[docs]
def fixed_frequency_cavity_ldos(
*,
peak_resonant_ldos: jax.Array,
quality_factor: jax.Array,
pole_frequency: jax.Array,
emitter_frequency: jax.Array,
background_ldos: float | jax.Array = 0.0,
) -> jax.Array:
"""Evaluate a fitted cavity pole at one immutable emitter frequency.
``peak_resonant_ldos`` is the resonant contribution at the pole, excluding
``background_ldos``. The returned value applies the single-pole
Lorentzian detuning factor
``1 / (1 + (2 Q (f_emitter - f_pole) / f_pole)**2)``.
Unlike optimizing peak ``Q/V`` while following a moving pole, this metric
gives no benefit to a resonance that walks away from the emitter. Every
operation remains differentiable with respect to the fitted pole, Q, mode
volume, and ultimately the FDTD geometry.
"""
dtype = jnp.result_type(
peak_resonant_ldos,
quality_factor,
pole_frequency,
emitter_frequency,
background_ldos,
jnp.float32,
)
tiny = jnp.asarray(jnp.finfo(dtype).tiny, dtype=dtype)
peak = jnp.maximum(jnp.asarray(peak_resonant_ldos, dtype=dtype), 0.0)
quality = jnp.maximum(jnp.asarray(quality_factor, dtype=dtype), tiny)
pole = jnp.maximum(jnp.asarray(pole_frequency, dtype=dtype), tiny)
emitter = jnp.maximum(jnp.asarray(emitter_frequency, dtype=dtype), tiny)
background = jnp.maximum(jnp.asarray(background_ldos, dtype=dtype), 0.0)
linewidth_offset = 2.0 * quality * (emitter - pole) / pole
return background + peak / (1.0 + linewidth_offset**2)
def remove_ringdown_dc(signal: jax.Array) -> jax.Array:
"""Remove the constant component of a one-dimensional detector trace."""
signal = jnp.asarray(signal)
if signal.ndim != 1:
raise ValueError(f"signal must be one-dimensional, got shape {signal.shape}")
return signal - jnp.mean(signal)
def ringdown_acquisition(
signal: jax.Array,
*,
samples_per_window: int,
num_windows: int | None = None,
) -> RingdownAcquisition:
"""Return a fit-free objective for acquiring an atom-coupled resonance.
The score is the logarithm of the RMS field in the final complete window.
Maximizing it rewards both excitation at the detector and persistence
after the source pulse, without assuming that a single exponential pole is
already identifiable. Once a reliable pole appears, callers can switch to
:func:`fit_resonance` for direct Q optimization.
"""
signal = remove_ringdown_dc(signal)
if samples_per_window < 2:
raise ValueError("samples_per_window must be at least 2")
available_windows = signal.shape[0] // samples_per_window
if num_windows is None:
num_windows = available_windows
if num_windows < 2:
raise ValueError("at least two complete windows are required")
if num_windows > available_windows:
raise ValueError(f"requested {num_windows} windows but only {available_windows} fit in the signal")
windows = signal[: num_windows * samples_per_window].reshape((num_windows, samples_per_window))
tiny = jnp.asarray(jnp.finfo(windows.dtype).tiny, dtype=windows.dtype)
rms = jnp.sqrt(jnp.mean(windows**2, axis=1) + tiny)
early_rms = rms[0]
late_rms = rms[-1]
retention = late_rms / jnp.maximum(early_rms, tiny)
return RingdownAcquisition(
early_rms=early_rms,
late_rms=late_rms,
retention=retention,
score=jnp.log(jnp.maximum(late_rms, tiny)),
)
def coherent_ringdown_acquisition(
signal: jax.Array,
*,
time_step: float | jax.Array,
center_frequency: float | jax.Array,
samples_per_window: int,
num_windows: int | None = None,
retention_weight: float | jax.Array = 0.25,
) -> CoherentRingdownAcquisition:
"""Acquire a persistent ringdown specifically at ``center_frequency``.
Each complete window is Hann-weighted and coherently demodulated. The
score primarily maximizes the final-window target-frequency amplitude and
adds a smaller log-retention term. The latter favors persistence without
allowing an optimizer to win merely by suppressing initial excitation:
scaling the whole trace still changes the score with unit log slope.
This is intended for the topology-discovery interval before a clean pole
can be fitted. Once a reliable single pole exists, use
:func:`fit_resonance` and a direct Q/V objective.
"""
signal = jnp.asarray(signal)
if signal.ndim != 1:
raise ValueError(f"signal must be one-dimensional, got shape {signal.shape}")
if samples_per_window < 2:
raise ValueError("samples_per_window must be at least 2")
available_windows = signal.shape[0] // samples_per_window
if num_windows is None:
num_windows = available_windows
if num_windows < 2:
raise ValueError("at least two complete windows are required")
if num_windows > available_windows:
raise ValueError(f"requested {num_windows} windows but only {available_windows} fit in the signal")
count = num_windows * samples_per_window
windows = signal[:count].reshape((num_windows, samples_per_window))
dtype = windows.dtype
local_index = jnp.arange(samples_per_window, dtype=dtype)
hann = 0.5 - 0.5 * jnp.cos(2.0 * jnp.pi * local_index / jnp.asarray(samples_per_window - 1, dtype=dtype))
absolute_index = jnp.arange(count, dtype=dtype).reshape((num_windows, samples_per_window))
phase = (
2.0
* jnp.pi
* jnp.asarray(center_frequency, dtype=dtype)
* (jnp.asarray(time_step, dtype=dtype) * absolute_index)
)
# Fit cosine, sine, and a constant simultaneously. Orthogonalizing the
# target-frequency basis against DC per window avoids leakage when a window
# contains a non-integer number of optical cycles. It also makes the
# result independent of unused tail samples when an optimizer shortens its
# FDTD tape to the active acquisition window.
basis = jnp.stack((jnp.cos(phase), jnp.sin(phase), jnp.ones_like(phase)), axis=-1)
weighted_basis = basis * hann[jnp.newaxis, :, jnp.newaxis]
gram = jnp.einsum("wsi,wsj->wij", weighted_basis, basis)
rhs = jnp.einsum("wsi,ws->wi", weighted_basis, windows)
regularization = 10.0 * jnp.finfo(dtype).eps * jnp.trace(gram, axis1=-2, axis2=-1) / 3.0
gram = gram + regularization[:, jnp.newaxis, jnp.newaxis] * jnp.eye(3, dtype=dtype)
coefficients = jnp.linalg.solve(gram, rhs[..., jnp.newaxis])[..., 0]
tiny = jnp.asarray(jnp.finfo(dtype).tiny, dtype=dtype)
amplitudes = jnp.sqrt(coefficients[:, 0] ** 2 + coefficients[:, 1] ** 2 + tiny)
early_amplitude = amplitudes[0]
late_amplitude = amplitudes[-1]
retention = late_amplitude / jnp.maximum(early_amplitude, tiny)
late_centered = windows[-1] - coefficients[-1, 2]
late_rms = jnp.sqrt(jnp.mean(late_centered**2) + tiny)
late_coherence = late_amplitude / jnp.maximum(jnp.sqrt(2.0) * late_rms, tiny)
weight = jnp.asarray(retention_weight, dtype=dtype)
score = jnp.log(jnp.maximum(late_amplitude, tiny)) + weight * jnp.log(jnp.maximum(retention, tiny))
return CoherentRingdownAcquisition(
early_amplitude=early_amplitude,
late_amplitude=late_amplitude,
retention=retention,
late_coherence=late_coherence,
score=score,
)
def fit_resonance(
signal: jax.Array,
*,
time_step: float | jax.Array,
center_frequency: float | jax.Array,
samples_per_window: int,
start_index: int = 0,
num_windows: int | None = None,
) -> ResonanceEstimate:
"""Fit one complex resonance to a real-valued FDTD ringdown.
The fit is GPU-native and differentiable with respect to ``signal``,
``time_step``, and ``center_frequency``. ``samples_per_window``,
``start_index``, and ``num_windows`` define array shapes and are therefore
static Python values. The center frequency should already identify the
desired mode closely enough that its phase advances by less than ``pi``
between coherent windows.
Args:
signal: One-dimensional real detector trace after the excitation pulse.
time_step: Time between adjacent samples in seconds.
center_frequency: Tracking/demodulation frequency in Hz.
samples_per_window: Samples in each translated Hann window. A window
spanning tens of optical periods suppresses the counter-rotating
component of a real trace.
start_index: First sample included in the fit.
num_windows: Number of complete windows. By default, use every complete
window after ``start_index``.
Returns:
A :class:`ResonanceEstimate` containing only JAX arrays.
Raises:
ValueError: If the signal or static window configuration is invalid.
Notes:
Mode selection and early-stop decisions are intentionally outside this
function. Those operations are discrete and should be held fixed while
differentiating an optimization step.
"""
signal = jnp.asarray(signal)
if signal.ndim != 1:
raise ValueError(f"signal must be one-dimensional, got shape {signal.shape}")
if samples_per_window < 2:
raise ValueError("samples_per_window must be at least 2")
if start_index < 0 or start_index >= signal.shape[0]:
raise ValueError(f"start_index must be in [0, {signal.shape[0]}), got {start_index}")
available_windows = (signal.shape[0] - start_index) // samples_per_window
if num_windows is None:
num_windows = available_windows
if num_windows < 3:
raise ValueError("at least three complete coherent windows are required")
if num_windows > available_windows:
raise ValueError(f"requested {num_windows} windows but only {available_windows} fit in the signal")
sample_count = num_windows * samples_per_window
samples = signal[start_index : start_index + sample_count]
real_dtype = jnp.result_type(samples.dtype, jnp.float32)
samples = samples.astype(real_dtype)
dt = jnp.asarray(time_step, dtype=real_dtype)
reference = jnp.asarray(center_frequency, dtype=real_dtype)
# Avoid zero-weight endpoints while retaining the sidelobe suppression of a
# Hann window. Every block uses an identical translated window, which makes
# the single-exponential ratio exact in the absence of the counter-rotating
# component and other modes.
window = jnp.hanning(samples_per_window + 2).astype(real_dtype)[1:-1]
indices = start_index + jnp.arange(sample_count, dtype=real_dtype)
carrier = jnp.exp(-2j * jnp.pi * reference * dt * indices)
blocks = (samples * carrier).reshape((num_windows, samples_per_window))
amplitudes = jnp.sum(blocks * window[None, :], axis=1) / jnp.sum(window)
previous = amplitudes[:-1]
following = amplitudes[1:]
tiny = jnp.asarray(jnp.finfo(real_dtype).tiny, dtype=real_dtype)
denominator = jnp.real(jnp.vdot(previous, previous)) + tiny
pole = jnp.vdot(previous, following) / denominator
window_duration = dt * samples_per_window
pole_magnitude = jnp.abs(pole)
decay_rate = -jnp.log(pole_magnitude) / window_duration
frequency = reference + jnp.angle(pole) / (2 * jnp.pi * window_duration)
quality_factor = jnp.pi * frequency / decay_rate
prediction_error = following - pole * previous
residual = jnp.sqrt(
jnp.real(jnp.vdot(prediction_error, prediction_error))
/ (jnp.real(jnp.vdot(following, following)) + tiny)
+ tiny
)
adjacent_magnitudes = jnp.maximum(jnp.abs(following / previous), tiny)
adjacent_decay = -jnp.log(adjacent_magnitudes) / window_duration
decay_mean = jnp.mean(adjacent_decay)
window_consistency = jnp.sqrt(
jnp.mean((adjacent_decay - decay_mean) ** 2) + tiny
) / (jnp.abs(decay_mean) + tiny)
return ResonanceEstimate(
frequency=frequency,
decay_rate=decay_rate,
quality_factor=quality_factor,
pole=pole,
residual=residual,
window_consistency=window_consistency,
amplitudes=amplitudes,
)
def fit_energy_decay(
energy: jax.Array,
*,
time_step: float | jax.Array,
frequency: float | jax.Array,
samples_per_window: int,
start_index: int = 0,
num_windows: int | None = None,
) -> EnergyDecayEstimate:
"""Fit a differentiable exponential lifetime to total modal energy.
This is a complementary check on an atom-point field fit. A local field
trace can overestimate lifetime when a weak, slowly decaying tail is not
the dominant energy-carrying cavity mode. Fitting total electromagnetic
energy makes that failure mode visible while retaining gradients through
the FDTD tape.
``energy`` must be a non-negative one-dimensional detector trace. Equal
windows are averaged before fitting in log space, which suppresses the
electric/magnetic exchange visible in instantaneous Yee-grid samples.
At least three windows are required so fit residual and adjacent-window
consistency remain independently measurable.
"""
energy = jnp.asarray(energy)
if energy.ndim != 1:
raise ValueError(f"energy must be one-dimensional, got shape {energy.shape}")
if samples_per_window < 2:
raise ValueError("samples_per_window must be at least 2")
if start_index < 0 or start_index >= energy.shape[0]:
raise ValueError(f"start_index must be in [0, {energy.shape[0]}), got {start_index}")
available_windows = (energy.shape[0] - start_index) // samples_per_window
if num_windows is None:
num_windows = available_windows
if num_windows < 3:
raise ValueError("at least three complete energy windows are required")
if num_windows > available_windows:
raise ValueError(f"requested {num_windows} windows but only {available_windows} fit in the energy trace")
count = num_windows * samples_per_window
real_dtype = jnp.result_type(energy.dtype, jnp.float32)
samples = energy[start_index : start_index + count].astype(real_dtype)
windows = samples.reshape((num_windows, samples_per_window))
tiny = jnp.asarray(jnp.finfo(real_dtype).tiny, dtype=real_dtype)
window_energies = jnp.maximum(jnp.mean(windows, axis=1), tiny)
log_energy = jnp.log(window_energies)
# Regress against a centered unitless window index. This avoids the poor
# conditioning of optical times expressed as ~1e-12 seconds in float32.
index = jnp.arange(num_windows, dtype=real_dtype)
centered_index = index - jnp.mean(index)
centered_log_energy = log_energy - jnp.mean(log_energy)
slope_per_window = jnp.vdot(centered_index, centered_log_energy) / jnp.vdot(centered_index, centered_index)
window_duration = jnp.asarray(time_step, dtype=real_dtype) * jnp.asarray(samples_per_window, dtype=real_dtype)
decay_rate = -slope_per_window / window_duration
frequency = jnp.asarray(frequency, dtype=real_dtype)
quality_factor = 2.0 * jnp.pi * frequency / decay_rate
prediction = jnp.mean(log_energy) + slope_per_window * centered_index
residual = jnp.sqrt(jnp.mean((log_energy - prediction) ** 2) + tiny)
adjacent_decay = -jnp.diff(log_energy) / window_duration
decay_mean = jnp.mean(adjacent_decay)
window_consistency = jnp.sqrt(
jnp.mean((adjacent_decay - decay_mean) ** 2) + tiny
) / (jnp.abs(decay_mean) + tiny)
return EnergyDecayEstimate(
decay_rate=decay_rate,
quality_factor=quality_factor,
residual=residual,
window_consistency=window_consistency,
window_energies=window_energies,
)