# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""The boundary monitor's transverse edge (SEP 40 section 5.3).
A free-space run has a wall or a wrap at its grid's outer size. With no real-space
collar, whatever reaches the edge comes back, so reaching it is the failure: the
monitor judges, per face, the energy in the outer 5 % of the *outer* size, its largest
reading above its launch value, less a noise allowance, at the levels of
:class:`solprop.monitor.BoundaryCheck`. With a collar, reaching the edge is intended and
what returns is the failure; the outward shares are recorded at INFO and the collar's
removal is credited.
Reads are spaced so that light cannot cross the edge unseen between two of them,
``min(L/16, 4 g / v_q)`` with ``g`` the guard's width and ``v_q`` the reach of the
current spectrum, and the spacing is a ceiling on the step. The safety ramp's removal
is credited per read interval and judged above the realised input noise's own share.
Every run reports a ``transverse_status``; only ``clear`` is a clean verdict.
The field read is the backend's committed spectral state; the reduction is the host's,
which ``numpy_ref`` carries in phase 1 of SEP 40. A backend that does not read the edge
leaves the run ``unwatched``, which it says before the first step.
"""
from __future__ import annotations
import logging
import math
import warnings
from dataclasses import dataclass, field
from typing import Literal, cast
import numpy as np
import scipy.fft
from jaxtyping import Complex, Float, Int
from solprop._process import thread_budget
from solprop.exceptions import BoundaryError, BoundaryWarning
from solprop.grid import FreeGrid, LineGrid, RadialGrid
from solprop.modes.slab import PlanarGrid
from solprop.spatial_absorber import RAMP_K_FLOOR, SpatialAbsorberDesign
__all__ = [
"GUARD",
"NOISE_FACTOR",
"READS",
"READ_RESOLUTION",
"TransverseEdge",
"TransverseStatus",
]
logger = logging.getLogger(__name__)
GUARD = 0.05
"""The edge's width as a fraction of the outer size on its axis (SEP 40 section 5.3)."""
READS = 16
"""The read spacing's ceiling is the guide's length over this many."""
NOISE_FACTOR = 8.0
"""Standard deviations of one noise reading the allowance takes (section 5.3)."""
_SMALL_TRANSFORM = 2**18
"""Host transforms below this many elements run on one thread."""
READ_RESOLUTION = 1e-3
"""A read may leave out the frequencies whose energy together is at most this fraction
of the warning (``budget**2`` of the launch energy). By Parseval a frequency cannot put
more on a face than it holds, so a face's share reads low by at most that, a thousandth
of what warns. A run's light occupies a fraction of a grid's frequencies at this level
(from a fifth to a third on filaments and Kerr beams), and each read would otherwise
synthesise them all (issue #453)."""
TransverseStatus = Literal[
"clear", "warned", "stopped", "insensitive", "coarse", "unwatched", "off"
]
@dataclass(frozen=True)
class _Face:
"""One face of the transverse edge.
Attributes
----------
name : str
``"r"``, ``"x-"``, ``"x+"``, ``"y-"`` or ``"y+"``.
edge : numpy.ndarray
Nodes (or rows) in the outer :data:`GUARD` of the outer size.
clip : numpy.ndarray
Nodes in the outer :data:`GUARD` of the requested size, for the launch.
"""
name: str
edge: np.ndarray
clip: np.ndarray
def _row_energy(rows: np.ndarray) -> Float[np.ndarray, " n_rows"]:
"""Return each row's ``sum |E|^2`` of a two-dimensional complex array.
Double precision is summed as a real view by ``einsum``, without the temporaries
of ``abs(E)**2``; a read makes these sums many times a run (section 7.7).
Parameters
----------
rows : numpy.ndarray
Complex rows.
Returns
-------
numpy.ndarray
The per-row sums.
"""
if rows.dtype != np.complex128:
return np.sum(np.abs(rows) ** 2, axis=1)
flat = np.ascontiguousarray(rows).view(np.float64)
return np.einsum("ij,ij->i", flat, flat)
def _axis_faces(
name: str, axis: np.ndarray, size: float, flat: np.ndarray
) -> tuple[_Face, _Face]:
"""Return an axis's low and high faces.
Parameters
----------
name : str
The axis, ``"x"`` or ``"y"``.
axis : numpy.ndarray
Its centred samples, m.
size : float
Its requested half-size, m.
flat : numpy.ndarray
Each node's coordinate along it, m.
Returns
-------
tuple of _Face
The two faces.
"""
outer = 0.5 * axis.size * float(axis[1] - axis[0])
low = _Face(
f"{name}-",
flat <= -(1 - GUARD) * outer,
(flat <= -(1 - GUARD) * size) & (flat >= -size),
)
high = _Face(
f"{name}+",
flat >= (1 - GUARD) * outer - 0.5 * float(axis[1] - axis[0]),
(flat >= (1 - GUARD) * size) & (flat <= size),
)
return low, high
def _faces(space: RadialGrid | FreeGrid | PlanarGrid) -> tuple[_Face, ...]:
"""Return the faces of a grid's edge on its physical nodes or rows.
Parameters
----------
space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid
The grid.
Returns
-------
tuple of _Face
One radially, four on a Cartesian box, two on a line or a slab.
"""
if isinstance(space, RadialGrid):
r = np.asarray(space.r)
return (
_Face(
"r",
r >= (1 - GUARD) * space.outer_radius,
(r >= (1 - GUARD) * space.radius) & (r <= space.radius),
),
)
if isinstance(space, PlanarGrid):
y = np.asarray(space.y)
return _axis_faces("y", y, space.radius_y, y)
x = np.tile(np.asarray(space.x), space.n_y)
faces = _axis_faces("x", np.asarray(space.x), space.radius_x, x)
if isinstance(space, LineGrid):
return faces
y = np.repeat(np.asarray(space.y), space.n_x)
return faces + _axis_faces("y", np.asarray(space.y), space.radius_y, y)
def _guard_width(space: RadialGrid | FreeGrid | PlanarGrid) -> float:
"""Return the narrowest face's guard width, m.
Parameters
----------
space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid
The grid.
Returns
-------
float
``GUARD`` times the smallest outer half-size.
"""
if isinstance(space, RadialGrid):
return GUARD * space.outer_radius
if isinstance(space, PlanarGrid):
return GUARD * 0.5 * space.n_y * float(space.y[1] - space.y[0])
widths = [0.5 * space.n_x * float(space.x[1] - space.x[0])]
if not isinstance(space, LineGrid):
widths.append(0.5 * space.n_y * float(space.y[1] - space.y[0]))
return GUARD * min(widths)
[docs]
@dataclass
class TransverseEdge:
"""The transverse edge of one free-space run: geometry, totals and record.
Attributes
----------
space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or PlanarGrid
The run's grid.
design : solprop.spatial_absorber.SpatialAbsorberDesign or None
Its transverse boundary; ``None`` for ``spatial_absorber=False``.
budget : float
The warning level as a field amplitude: energy shares above its square warn.
stop_factor : float
Budgets above which a share stops the run.
length : float
The guide's length, m.
band : numpy.ndarray
The requested band's bins on the run's frequency axis.
k_min : float
The smallest wavenumber over the requested band, rad/m.
"""
space: RadialGrid | FreeGrid | PlanarGrid
design: SpatialAbsorberDesign | None
budget: float
stop_factor: float
length: float
band: Int[np.ndarray, " n_band"]
k_min: float
_faces: tuple[_Face, ...] = field(init=False)
_weights: np.ndarray = field(init=False)
_columns: np.ndarray = field(init=False)
_kappa: np.ndarray = field(init=False)
_guard: float = field(init=False)
_launch: float = field(default=math.nan, init=False)
_baseline: list[float] = field(default_factory=list, init=False)
_peak: list[float] = field(default_factory=list, init=False)
_peak_raw: list[float] = field(default_factory=list, init=False)
_allowance: float = field(default=0.0, init=False)
_noise_columns: np.ndarray | None = field(default=None, init=False)
_noise_share_in_ramp: float = field(default=0.0, init=False)
_ramp_raw: float = field(default=0.0, init=False)
_ramp_noise: float = field(default=0.0, init=False)
_spatial_removed: float = field(default=0.0, init=False)
_last_rate: tuple[float, float] | None = field(default=None, init=False)
_last_columns: np.ndarray | None = field(default=None, init=False)
_last_z: float = field(default=math.nan, init=False)
_next: float = field(default=math.inf, init=False)
_spacing_now: float = field(default=math.inf, init=False)
_reads: int = field(default=0, init=False)
_largest_gap: float = field(default=0.0, init=False)
_warned_at: float = field(default=math.nan, init=False)
_status: TransverseStatus = field(default="clear", init=False)
_reason: str = field(default="", init=False)
_warned: bool = field(default=False, init=False)
_ramp_warned: bool = field(default=False, init=False)
_resumed: bool = field(default=False, init=False)
_declared_share: float = field(default=0.0, init=False)
_strip_baseline: float = field(default=0.0, init=False)
_strip_floor: float = field(default=0.0, init=False)
_strip_peak: float = field(default=0.0, init=False)
_current: float = field(default=0.0, init=False)
_current_last: tuple[float, float] | None = field(default=None, init=False)
_current_baseline: float = field(default=0.0, init=False)
_returned_peak: float = field(default=0.0, init=False)
_inverse: np.ndarray | None = field(default=None, init=False)
_read_rows: np.ndarray | None = field(default=None, init=False)
_kappa_order: np.ndarray = field(init=False)
_workers: int = field(default=1, init=False)
def __post_init__(self) -> None:
"""Build the faces, the node weights and the column wavenumbers."""
space = self.space
self._faces = _faces(space)
self._guard = _guard_width(space)
if isinstance(space, RadialGrid):
self._weights = np.asarray(space.radial_weights, dtype=np.float64)
self._kappa = np.asarray(space.k_r, dtype=np.float64)
elif isinstance(space, PlanarGrid):
self._weights = np.full(space.n_y, float(space.y[1] - space.y[0]))
self._kappa = np.abs(np.repeat(np.asarray(space.k_y), space.n_p))
else:
self._weights = np.asarray(space.spatial_weights, dtype=np.float64)
self._kappa = np.sqrt(np.asarray(space.kperp_sq, dtype=np.float64))
self._kappa_order = np.argsort(self._kappa)
self._columns = np.asarray(space.spectral_weights, dtype=np.float64)
self._peak = [0.0] * len(self._faces)
self._peak_raw = [0.0] * len(self._faces)
self._baseline = [0.0] * len(self._faces)
# The host's transverse transforms take the process's thread budget (ADR-052).
self._workers = thread_budget().threads
# ------------------------------------------------------------------ reductions
def _node_power(
self,
state: Complex[np.ndarray, "n_modes n_pol n_omega"],
*,
read: bool = False,
) -> Float[np.ndarray, " n_nodes"]:
"""Return each physical node's (or row's) energy, summed over pol and frequency.
Parameters
----------
state : numpy.ndarray
One batch member's spectral columns.
read : bool, optional
Whether only the rows a read uses are wanted: the edge's faces, and a
requested collar's (:meth:`_rows_read`). Radially the others are not
synthesised and are zero; a read happens many times a run, and the full
synthesis is an ``n_r``-square product per frequency.
Returns
-------
numpy.ndarray
``|E|^2`` summed, unweighted, in the state's units squared.
"""
space = self.space
flat = state.reshape(state.shape[0], -1)
if read:
flat = self._occupied_planes(flat)
if isinstance(space, RadialGrid):
if self._inverse is None:
hankel = space.hankel
self._inverse = (
hankel.inverse_scale
* (hankel.j1[:, None] / hankel.j1[None, :])
* hankel.matrix
)
if not read:
return _row_energy(self._inverse @ flat)
rows = self._rows_read()
nodes = np.zeros(space.n_r)
nodes[rows] = _row_energy(self._inverse[rows] @ flat)
return nodes
if isinstance(space, PlanarGrid):
cube = flat.reshape(space.n_y, space.n_p, -1)
physical = scipy.fft.ifft(cube, axis=0, workers=self._fft_workers(cube))
physical = physical.reshape(space.n_y, -1)
else:
cube = flat.reshape(space.n_y, space.n_x, -1)
workers = self._fft_workers(cube)
physical = scipy.fft.ifftn(cube, axes=(0, 1), workers=workers)
physical = physical.reshape(space.n_y * space.n_x, -1)
if not read:
return _row_energy(physical)
rows = self._rows_read()
nodes = np.zeros(physical.shape[0])
nodes[rows] = _row_energy(physical[rows])
return nodes
def _occupied_planes(
self, flat: Complex[np.ndarray, "n_columns n_planes"]
) -> Complex[np.ndarray, "n_columns n_kept"]:
"""Return the frequency planes a read must synthesise (:data:`READ_RESOLUTION`).
A requested collar's rate is credited as a removal rather than judged against
the warning, so it reads every plane. So does a state that fills more than half
of them, where the copy would cost more than the planes it leaves out.
Parameters
----------
flat : numpy.ndarray
One member's spectral columns, a plane being one polarisation's frequency.
Returns
-------
numpy.ndarray
`flat` itself, or a copy holding only the planes kept.
"""
if self.design is not None and self.design.has_collar:
return flat
real, imaginary = np.real(flat), np.imag(flat)
energy = np.einsum("i,ij,ij->j", self._columns, real, real) + np.einsum(
"i,ij,ij->j", self._columns, imaginary, imaginary
)
order = np.argsort(energy)
limit = READ_RESOLUTION * self.budget**2 * self._launch
left_out = int(np.searchsorted(np.cumsum(energy[order]), limit, side="right"))
if 2 * left_out < energy.size:
return flat
return flat[:, np.sort(order[left_out:])]
def _fft_workers(self, cube: np.ndarray) -> int:
"""Return the threads for one host transform of `cube`.
A small transform is faster on one thread than on a pool woken beside the
backend's own threads.
Parameters
----------
cube : numpy.ndarray
The array transformed.
Returns
-------
int
The thread budget's threads, or 1 below ``2**18`` elements.
"""
return self._workers if cube.size >= _SMALL_TRANSFORM else 1
def _rows_read(self) -> np.ndarray:
"""Return the nodes (or rows) a read uses: the faces' and a collar's.
Returns
-------
numpy.ndarray
A mask over the nodes.
"""
if self._read_rows is None:
rows = np.zeros(self._weights.size, dtype=bool)
for face in self._faces:
rows |= face.edge
rate = self._node_rate()
if rate is not None:
rows |= rate > 0.0
self._read_rows = rows
return self._read_rows
def _node_rate(self) -> Float[np.ndarray, " n_nodes"] | None:
"""Return a requested collar's power rate on the nodes (or rows), 1/m.
Returns
-------
numpy.ndarray or None
The rate; ``None`` without a collar. On a slab it depends on y alone.
"""
if self.design is None or self.design.collar_rate is None:
return None
rate = np.asarray(self.design.collar_rate)
if isinstance(self.space, PlanarGrid):
rate = rate.reshape(self.space.n_y, -1)[:, 0]
return rate
def _column_power(
self, state: Complex[np.ndarray, "n_modes n_pol n_omega"]
) -> Float[np.ndarray, " n_columns"]:
"""Return each spectral column's energy, in the node reduction's units.
Parameters
----------
state : numpy.ndarray
One batch member's spectral columns.
Returns
-------
numpy.ndarray
Weighted ``|E|^2``, scaled so that its sum is the node reduction's total.
"""
return self._columns * _row_energy(state.reshape(state.shape[0], -1))
# ------------------------------------------------------------------ the record
[docs]
def launch( # noqa: PLR0915 - the launch, its noise and its judgements, in order
self,
state: Complex[np.ndarray, "n_modes n_pol n_omega"],
z: float,
*,
noise: Complex[np.ndarray, "n_modes n_pol n_omega"] | None,
steps: Float[np.ndarray, " n_steps"] | None,
reads: bool,
declared: bool,
) -> None:
"""Record the launch, judge it, and set the first read.
Parameters
----------
state : numpy.ndarray
The launched state, one member, spectral columns.
z : float
Its position, m.
noise : numpy.ndarray or None
The input noise it carries, if any.
steps : numpy.ndarray or None
A fixed schedule's steps, which the read spacing cannot limit.
reads : bool
Whether the backend reads the edge.
declared : bool
Whether the user declared content above ``discard_above``.
Raises
------
BoundaryError
When physical light launched into the safety ramp's band exceeds ten
budgets (section 5.3).
"""
nodes = self._node_power(state)
weighted = self._weights * nodes
if self._resumed:
# The launch was judged by the first execution; its baselines and totals
# are restored, and the reads continue from the resumed state.
self._last_z = z
if noise is not None:
self._noise_columns = self._column_power(noise)
self._last_columns = self._column_power(state)
self._spacing_now = self._spacing(self._last_columns)
self._next = z + self._spacing_now if reads else math.inf
self._last_rate = (z, self._collar_rate(nodes))
if not reads:
self._status = "unwatched"
return
self._launch = float(np.sum(weighted))
self._last_z = z
if not self._launch > 0.0:
self._next = math.inf
return
warn, stop = self.budget**2, (self.stop_factor * self.budget) ** 2
# About the launch, not the backend: judged whether or not the edge is read.
self._judge_ramp_band(
state if noise is None else state - noise, stop, warn, declared=declared
)
if not reads:
self._status = "unwatched"
self._reason = "this backend does not read the transverse edge"
warnings.warn(
"this backend does not read the transverse edge of a free-space run: "
"the run is unwatched there and cannot report a clean verdict; "
"solprop's own backends read it (SEP 40 section 5.3)",
BoundaryWarning,
stacklevel=4,
)
return
for index, face in enumerate(self._faces):
share = float(np.sum(weighted[face.edge])) / self._launch
self._baseline[index] = share
clipped = float(np.sum(weighted[face.clip])) / self._launch
if clipped > warn:
warnings.warn(
f"the launch holds {clipped:.2e} of its energy in the outer "
f"{GUARD:.0%} of the requested size on its {face.name} face: the "
"aperture clips it",
BoundaryWarning,
stacklevel=4,
)
if noise is not None:
noise_nodes = self._weights * self._node_power(noise)
eta = float(np.sum(noise_nodes)) / self._launch
share = float(np.sum(self._weights[self._faces[0].edge])) / float(
np.sum(self._weights)
)
modes = max(int(np.count_nonzero(self._faces[0].edge)), 1) * max(
int(self.band.size), 1
)
self._allowance = NOISE_FACTOR * eta * share / math.sqrt(modes)
self._noise_columns = self._column_power(noise)
if self.design is not None:
ramp = self.design.ramp_rate > 0.0
self._noise_share_in_ramp = (
float(np.sum(self._noise_columns[ramp])) / self._launch
)
if self._allowance > warn:
self._status = "insensitive"
self._reason = (
f"the input noise's allowance {self._allowance:.2e} exceeds the "
"warning"
)
warnings.warn(
"the transverse edge cannot be judged below "
f"{self._allowance:.2e} of the launch energy under this input "
"noise (SEP 40 section 5.3)",
BoundaryWarning,
stacklevel=4,
)
self._last_columns = self._column_power(state)
spacing = self._spacing(self._last_columns)
self._spacing_now = spacing
self._next = z + spacing
if steps is not None and steps.size and float(np.max(steps)) > spacing:
self._status = "coarse" if self._status == "clear" else self._status
self._largest_gap = float(np.max(steps))
warnings.warn(
f"the fixed schedule's longest step, {float(np.max(steps)):.3e} m, is "
f"longer than the transverse edge's read spacing, {spacing:.3e} m: "
"light could cross the edge unseen between two reads; the run is "
"reported as coarse (SEP 40 section 5.3)",
BoundaryWarning,
stacklevel=4,
)
self._last_rate = (z, self._collar_rate(nodes))
if self.every_step:
self._launch_return(state, noise, weighted)
def _judge_ramp_band(
self,
launch: Complex[np.ndarray, "n_modes n_pol n_omega"],
stop: float,
warn: float,
*,
declared: bool,
) -> None:
"""Judge the noise-free launch's share in the safety ramp's band (5.3).
Parameters
----------
launch : numpy.ndarray
The launch as attached, less the input noise it carries.
stop, warn : float
The stop and warning levels as energy shares.
declared : bool
Whether content above ``discard_above`` was declared.
Raises
------
BoundaryError
Above the stop, unless declared.
"""
if self.design is None:
return
columns = self._column_power(launch)
total = float(np.sum(columns))
share = (
float(np.sum(columns[self.design.ramp_rate > 0.0])) / total
if total > 0.0
else 0.0
)
if declared:
# Declared above discard_above: not wanted, so its removal is not judged.
self._declared_share = share
logger.info("the launch holds %.2e of its energy in the ramp's band", share)
return
if share > stop:
msg = (
f"the launch holds {share:.2e} of its energy in the safety ramp's "
"band, which the ramp would erase: refine the transverse grid, launch "
"with less tilt, declare the content with "
"SpatialAbsorber(discard_above=...), or remove the ramp with "
"spatial_absorber=False (SEP 40 section 5.3)"
)
raise BoundaryError(msg)
if share > warn:
warnings.warn(
f"the launch holds {share:.2e} of its energy in the safety ramp's "
"band, which the ramp removes as the run starts",
BoundaryWarning,
stacklevel=5,
)
def _collar_rate(self, nodes: Float[np.ndarray, " n_nodes"]) -> float:
"""Return a requested collar's removal rate, as a share of the launch per m.
Parameters
----------
nodes : numpy.ndarray
Each node's unweighted energy.
Returns
-------
float
``sum alpha w |E|^2 / U_launch``; zero without a collar.
"""
rate = self._node_rate()
if rate is None:
return 0.0
return float(np.sum(rate * self._weights * nodes)) / self._launch
def _spacing(self, columns: Float[np.ndarray, " n_columns"]) -> float:
"""Return the read spacing for the current spectrum, ``min(L/16, 4 g / v_q)``.
Parameters
----------
columns : numpy.ndarray
One member's column energies (:meth:`_column_power`).
Returns
-------
float
The spacing, m.
"""
if self._noise_columns is not None:
columns = np.maximum(columns - self._noise_columns, 0.0)
order = self._kappa_order
tail = np.cumsum(columns[order][::-1])[::-1] / self._launch
above = np.nonzero(tail > self.budget**2)[0]
kappa_q = float(self._kappa[order][above[-1]]) if above.size else 0.0
k = self.k_min
k_z = math.sqrt(max(k * k - kappa_q * kappa_q, 0.0))
reach = kappa_q / max(k_z, RAMP_K_FLOOR * k)
spacing = self.length / READS
if reach > 0.0:
spacing = min(spacing, 4.0 * self._guard / reach)
return spacing
[docs]
def ceiling(self, z: float) -> float:
"""Return the longest step the reads allow from `z`: the read spacing.
A step is not made to land on a read. It is capped at the spacing, and the edge
is read at the last committed position from which the next step would carry
past the next read (:meth:`due`), so reads stay at most a spacing apart without
shortening the steps between them.
Parameters
----------
z : float
The current position, m.
Returns
-------
float
The step, m; infinite when nothing is read.
"""
del z # the spacing does not depend on the position between reads
if not math.isfinite(self._next):
return math.inf
return self._spacing_now
[docs]
def due(self, z: float, proposal: float = 0.0) -> bool:
"""Return whether the edge is read at the committed position `z`.
Parameters
----------
z : float
The committed position, m.
proposal : float, optional
The next step proposed from `z`, m: the edge is read now when that step
would carry past the next read.
Returns
-------
bool
True to call :meth:`read`.
"""
return z + proposal >= self._next - 1e-12 * max(abs(z), 1.0)
[docs]
def wants_read(self, z: float) -> bool:
"""Return whether a read at `z` would record anything.
A saved plane reads the edge whether or not the step that reached it already
did, and a read at the position of the last one records nothing. Asking first
spares the caller the state it would otherwise hand over for nothing, which on
a device is a crossing of the whole state.
Parameters
----------
z : float
The committed position, m.
Returns
-------
bool
False when nothing was launched, the edge is unwatched, or it was last
read at `z`.
"""
return self._launch > 0.0 and self._status != "unwatched" and z > self._last_z
[docs]
def read(
self, state: Complex[np.ndarray, "n_modes n_pol n_omega"], z: float
) -> None:
"""Read the edge at a committed position, credit the removals and judge.
Parameters
----------
state : numpy.ndarray
One member's spectral columns.
z : float
Its position, m.
Raises
------
BoundaryError
When a face's judged share exceeds ``stop_factor`` budgets.
"""
if not self.wants_read(z):
return
nodes = self._node_power(state, read=True)
weighted = self._weights * nodes
gap = z - self._last_z
self._reads += 1
self._largest_gap = max(self._largest_gap, gap)
# The ramp's removal over the interval, from each column's power at its start.
if self.design is not None and self._last_columns is not None:
taken = -np.expm1(-self.design.ramp_rate * gap)
self._ramp_raw += float(np.sum(self._last_columns * taken)) / self._launch
if self._noise_columns is not None:
self._ramp_noise += float(np.sum(self._noise_columns * taken)) / (
self._launch
)
rate = self._collar_rate(nodes)
# Every launch that leaves the edge read sets the rate's first sample.
assert self._last_rate is not None
previous_z, previous = self._last_rate
self._spatial_removed += 0.5 * (z - previous_z) * (previous + rate)
self._last_rate = (z, rate)
self._last_z = z
self._last_columns = self._column_power(state)
self._spacing_now = self._spacing(self._last_columns)
self._next = z + self._spacing_now
if self.design is not None and self.design.has_collar:
# With a collar, outward flow is intended: recorded at INFO, never stopped.
for index, face in enumerate(self._faces):
share = float(np.sum(weighted[face.edge])) / self._launch
self._peak_raw[index] = max(self._peak_raw[index], share)
self._judge_return(state, z)
self._judge_ramp_removal()
return
warn, stop = self.budget**2, (self.stop_factor * self.budget) ** 2
judgements = []
for index, face in enumerate(self._faces):
share = float(np.sum(weighted[face.edge])) / self._launch
self._peak_raw[index] = max(self._peak_raw[index], share)
judged = share - self._baseline[index] - self._allowance
self._peak[index] = max(self._peak[index], judged)
judgements.append((judged, face.name))
worst, name = max(judgements)
if worst > stop:
self._status = "stopped"
raise BoundaryError(self._stop_message(name, worst, z))
for judged, face_name in judgements:
if judged > warn and not self._warned:
self._warned = True
self._warned_at = z
if self._status in ("clear", "coarse"):
self._status = "warned"
warnings.warn(
f"light reached the transverse edge on its {face_name} face at "
f"z = {z:.4e} m: {judged:.2e} of the launch energy above its "
"launch value, which the wall or the wrap returns (SEP 40 "
"section 5.3)",
BoundaryWarning,
stacklevel=4,
)
self._judge_ramp_removal()
# ------------------------------------------------------------------ a collar
def _strip_bounds(self) -> tuple[float, float]:
"""Return a collar's return strip, ``(a, b)`` in the outward coordinate, m.
The collar plus ``min(w/2, R/4)`` of the request (section 5.3).
Returns
-------
tuple of float
The strip's inner and outer edges.
"""
assert self.design is not None
width = min(self.design.width)
size = self._requested_size()
return size - min(0.5 * width, 0.25 * size), size + width
def _requested_size(self) -> float:
"""Return the requested half-size on the narrowest axis, m.
Returns
-------
float
The size, m.
"""
space = self.space
if isinstance(space, RadialGrid):
return space.radius
if isinstance(space, PlanarGrid):
return space.radius_y
if isinstance(space, LineGrid):
return space.radius_x
return min(space.radius_x, space.radius_y)
def _lines(
self, state: Complex[np.ndarray, "n_modes n_pol n_omega"]
) -> list[tuple[np.ndarray, np.ndarray, float]]:
"""Return the field along each face's outward normal, line by line.
Parameters
----------
state : numpy.ndarray
One member's spectral columns.
Returns
-------
list of tuple
``(s, values, measure)``: the outward coordinate of the line's nodes, m;
the field on them, ``(n_nodes, n_rows)`` with one row per frequency,
polarisation and transverse line (``sqrt(r) E`` radially); and the
measure each row's sum is multiplied by.
"""
space = self.space
flat = state.reshape(state.shape[0], -1)
if isinstance(space, RadialGrid):
hankel = space.hankel
inverse = (
hankel.inverse_scale
* (hankel.j1[:, None] / hankel.j1[None, :])
* hankel.matrix
)
r = np.asarray(space.r)
return [(r, np.sqrt(r)[:, None] * (inverse @ flat), 2 * np.pi)]
if isinstance(space, PlanarGrid):
cube = np.fft.ifft(flat.reshape(space.n_y, space.n_p, -1), axis=0)
rows = cube.reshape(space.n_y, -1)
y = np.asarray(space.y)
return [(y, rows, 1.0), (-y, rows, 1.0)]
cube = np.fft.ifftn(flat.reshape(space.n_y, space.n_x, -1), axes=(0, 1))
x = np.asarray(space.x)
dy = 1.0 if isinstance(space, LineGrid) else float(space.y[1] - space.y[0])
along_x = np.moveaxis(cube, 1, 0).reshape(space.n_x, -1)
lines = [(x, along_x, dy), (-x, along_x, dy)]
if not isinstance(space, LineGrid):
y = np.asarray(space.y)
dx = float(space.x[1] - space.x[0])
along_y = cube.reshape(space.n_y, -1)
lines += [(y, along_y, dx), (-y, along_y, dx)]
return lines
def _strip_inward(
self, state: Complex[np.ndarray, "n_modes n_pol n_omega"]
) -> tuple[float, float]:
"""Return the strip's resolved inward energy and its windowed energy.
A Kaiser(8)-windowed transform along each face's normal, padded twice; the
inward energy is that at ``|q| >= 16 / s`` travelling inward, where the
window's leakage is about ``1e-6`` (section 5.3). The larger face's, as
shares of the launch.
Parameters
----------
state : numpy.ndarray
One member's spectral columns.
Returns
-------
tuple of float
The inward share and the windowed share.
"""
a, b = self._strip_bounds()
resolved = 16.0 / (b - a)
best, windowed = 0.0, 0.0
for s, values, measure in self._lines(state):
select = (s >= a) & (s <= b)
if np.count_nonzero(select) < 4:
continue
order = np.argsort(s[select])
coordinate = s[select][order]
spacing = float(np.mean(np.diff(coordinate)))
u = 2 * (coordinate - a) / (b - a) - 1
window = np.i0(8.0 * np.sqrt(np.clip(1 - u * u, 0.0, 1.0))) / np.i0(8.0)
lines = window[:, None] * values[select][order]
count = 2 * coordinate.size
spectrum = np.abs(np.fft.fft(lines, count, axis=0)) ** 2 * spacing / count
q = 2 * np.pi * np.fft.fftfreq(count, d=spacing)
# solprop propagates as exp(-i k_z z) (the operator carries -i(k_z - k)), so
# a component exp(i q s) travels towards smaller s: inward is q > 0.
inward = float(np.sum(spectrum[q >= resolved])) * measure / self._launch
total = float(np.sum(spectrum)) * measure / self._launch
if inward > best:
best, windowed = inward, total
return best, windowed
def _onset_current(
self, state: Complex[np.ndarray, "n_modes n_pol n_omega"]
) -> float:
"""Return the inward energy current across the collar's onset, rectified.
``J = Im(E* dE/dn) / k`` on the requested size's boundary, summed over its
frequencies and lines, per metre of propagation, as a share of the launch,
counting inward flow only (section 5.3).
Parameters
----------
state : numpy.ndarray
One member's spectral columns.
Returns
-------
float
The current, 1/m.
"""
size = self._requested_size()
total = 0.0
for s, values, measure in self._lines(state):
index = int(np.argmin(np.abs(s - size)))
# A node without a neighbour on each side has no slope to read.
order = np.argsort(s)
position = int(np.nonzero(order == index)[0][0])
if position == 0 or position == s.size - 1:
continue
before, after = order[position - 1], order[position + 1]
slope = (values[after] - values[before]) / float(s[after] - s[before])
# Under exp(-i k_z z), Im(E* dE/ds) / k is the flux towards smaller s.
flux = np.imag(np.conj(values[index]) * slope) / self.k_min
total += float(np.sum(np.maximum(flux, 0.0))) * measure
return total / self._launch
[docs]
def step(
self, state: Complex[np.ndarray, "n_modes n_pol n_omega"], z: float
) -> None:
"""Accumulate a collar's inward current at a committed step (section 5.3).
Parameters
----------
state : numpy.ndarray
One member's spectral columns.
z : float
Its position, m.
"""
if (
self.design is None
or not self.design.has_collar
or not self._launch > 0.0
or self._status == "unwatched"
):
return
current = self._onset_current(state)
if self._current_last is not None:
previous_z, previous = self._current_last
self._current += 0.5 * (z - previous_z) * (previous + current)
self._current_last = (z, current)
[docs]
@property
def next_read(self) -> float:
"""Return where the edge is next read, m; infinite when nothing is read.
A backend that marches without the host, as the native Adams march does,
holds its steps to it as :meth:`ceiling` does and hands the state back there.
Returns
-------
float
The position, m.
"""
return self._next
[docs]
@property
def every_step(self) -> bool:
"""Return whether the edge accumulates at every committed step (a collar).
Returns
-------
bool
True with a requested collar.
"""
return self.design is not None and self.design.has_collar
def _launch_return(
self,
state: Complex[np.ndarray, "n_modes n_pol n_omega"],
noise: Complex[np.ndarray, "n_modes n_pol n_omega"] | None,
weighted: Float[np.ndarray, " n_nodes"],
) -> None:
"""Record a collar's launch baselines and floors (section 5.3).
Parameters
----------
state : numpy.ndarray
The launched state.
noise : numpy.ndarray or None
Its input noise.
weighted : numpy.ndarray
Each node's weighted energy.
"""
strip, _ = self._strip_inward(state)
self._strip_baseline = strip
if noise is not None:
self._strip_floor = 3.0 * self._strip_inward(noise)[0]
outside = np.ones(weighted.size, dtype=bool)
for face in self._faces:
outside &= ~face.clip
size = self._requested_size()
if isinstance(self.space, RadialGrid):
outside = np.asarray(self.space.r) > size
self._current_baseline = float(np.sum(weighted[outside])) / self._launch
self._current_last = None
self._current = 0.0
def _judge_return(
self, state: Complex[np.ndarray, "n_modes n_pol n_omega"], z: float
) -> None:
"""Judge what a collar returns: the strip and the current (section 5.3).
Parameters
----------
state : numpy.ndarray
One member's spectral columns.
z : float
Its position, m.
Raises
------
BoundaryError
When the returned share exceeds ``stop_factor`` budgets.
"""
strip, windowed = self._strip_inward(state)
self._strip_peak = max(self._strip_peak, strip)
judged_strip = max(
strip - self._strip_baseline - self._strip_floor - 1e-6 * windowed, 0.0
)
judged_current = max(self._current - self._current_baseline, 0.0)
returned = max(judged_strip, judged_current)
self._returned_peak = max(self._returned_peak, returned)
warn, stop = self.budget**2, (self.stop_factor * self.budget) ** 2
if returned > stop:
self._status = "stopped"
msg = (
f"the requested collar returns {returned:.2e} of the launch energy at "
f"z = {z:.4e} m, past the stop of {stop:.0e} (SEP 40 section 5.3): "
"it is absorbing too little of what reaches it; widen it, or give the "
"run more room"
)
raise BoundaryError(msg)
if returned > warn and not self._warned:
self._warned = True
self._warned_at = z
self._status = "warned"
warnings.warn(
f"the requested collar returns {returned:.2e} of the launch energy at "
f"z = {z:.4e} m (SEP 40 section 5.3)",
BoundaryWarning,
stacklevel=5,
)
def _judge_ramp_removal(self) -> None:
"""Warn, or stop, on what the safety ramp has removed (section 5.3, Q8).
The ramp's removal tracks the true error of an under-resolved run within a few
times, and the ramp can hide that light from the edge; it is judged at the
edge's levels (the maintainer's decision on Q8).
Raises
------
BoundaryError
When the removal exceeds ``stop_factor`` budgets.
"""
removed = self.ramp_removed
stop = (self.stop_factor * self.budget) ** 2
if removed > stop:
self._status = "stopped"
msg = (
f"the safety ramp has removed {removed:.2e} of the launch energy "
f"above the input noise's share, past the stop of {stop:.0e}: "
"content reached the grid's outer transverse wavenumbers, which the "
"grid cannot resolve; refine the transverse grid (SEP 40 section 5.3)"
)
raise BoundaryError(msg)
if removed > self.budget**2 and not self._ramp_warned:
self._ramp_warned = True
# Only a run below the warning throughout is clear (section 5.3).
if self._status in ("clear", "coarse"):
self._status = "warned"
warnings.warn(
f"the safety ramp has removed {removed:.2e} of the launch energy above "
"the input noise's share: content reached the grid's outer "
"wavenumbers; refine the transverse grid (SEP 40 section 5.3)",
BoundaryWarning,
stacklevel=5,
)
def _stop_message(self, face: str, judged: float, z: float) -> str:
"""Return the stop's message, naming a size and a sample count to try.
Parameters
----------
face : str
The face.
judged : float
Its judged share.
z : float
Where it stopped, m.
Returns
-------
str
The message.
"""
first = self._warned_at if math.isfinite(self._warned_at) else z
factor = self.length / max(first, 1e-300)
space = self.space
if isinstance(space, RadialGrid):
size = f"a radius of at least {factor * space.outer_radius:.3g} m"
count = f"n_r of at least {math.ceil(factor * space.n_r)}"
elif isinstance(space, PlanarGrid):
outer = 0.5 * space.n_y * float(space.y[1] - space.y[0])
size = f"a y half-width of at least {factor * outer:.3g} m"
count = f"n_y of at least {math.ceil(factor * space.n_y)}"
else:
outer = 0.5 * space.n_x * float(space.x[1] - space.x[0])
size = f"an x half-width of at least {factor * outer:.3g} m"
count = f"n_x of at least {math.ceil(factor * space.n_x)}"
return (
f"light reached the transverse edge on its {face} face at z = {z:.4e} m: "
f"{judged:.2e} of the launch energy above its launch value, past the "
f"stop of {(self.stop_factor * self.budget) ** 2:.0e}; the wall or the "
f"wrap returns it into the aperture. Try {size}, at the same spacing "
f"({count}); this is a lower bound, and light generated late may need "
"more. A user who knows better raises BoundaryCheck.transverse_budget"
)
# ------------------------------------------------------------------ results
[docs]
@property
def ramp_removed(self) -> float:
"""Return the safety ramp's removal above noise and declared content.
Returns
-------
float
A share of the launch energy, signed.
"""
return (
self._ramp_raw
- min(self._ramp_noise, self._noise_share_in_ramp)
- self._declared_share
)
[docs]
@property
def status(self) -> TransverseStatus:
"""Return the run's transverse status (section 5.3).
Returns
-------
str
``clear``, ``warned``, ``stopped``, ``insensitive``, ``coarse`` or
``unwatched``.
"""
return self._status
[docs]
def resume(self, record: dict[str, object]) -> None:
"""Continue an earlier execution's record of the edge (SEP 40 section 5.3).
The launch baselines and the totals are restored, so a resume judges against
the original launch; :meth:`launch` then records the resumed state unjudged.
Parameters
----------
record : dict
The ``transverse`` entry of the earlier monitor record.
"""
def number(name: str, default: float = 0.0) -> float:
"""Return a recorded number, or `default`.
Parameters
----------
name : str
The field.
default : float, optional
What an absent field stands for.
Returns
-------
float
The value.
"""
value = record.get(name)
if isinstance(value, bool) or not isinstance(value, int | float):
return default
return float(value)
def numbers(name: str) -> list[float] | None:
"""Return a recorded per-face list, or ``None`` if it does not fit.
Parameters
----------
name : str
The field.
Returns
-------
list of float or None
One value per face.
"""
value = record.get(name)
if not isinstance(value, list) or len(value) != len(self._faces):
return None
return [float(cast("float", one)) for one in cast("list[object]", value)]
launch = number("launch_energy", math.nan)
baseline = numbers("edge_baseline")
if not launch > 0.0 or baseline is None:
return
self._resumed = True
self._launch = launch
self._baseline = baseline
self._peak = numbers("edge_max") or self._peak
self._peak_raw = numbers("edge_max_raw") or self._peak_raw
self._allowance = number("noise_allowance")
self._ramp_raw = number("raw_ramp_removed")
self._ramp_noise = number("noise_ramp_removed")
self._noise_share_in_ramp = number("noise_share_in_ramp")
self._declared_share = number("declared_share_in_ramp")
self._spatial_removed = number("spatial_removed")
self._reads = int(number("reads"))
self._largest_gap = number("largest_gap")
self._warned_at = number("warned_at", math.nan)
self._warned = math.isfinite(self._warned_at)
status = record.get("transverse_status")
if isinstance(status, str) and status in (
"clear",
"warned",
"insensitive",
"coarse",
):
self._status = cast("TransverseStatus", status)
[docs]
def summary(self) -> dict[str, object]:
"""Return the edge's record for the monitor's.
Returns
-------
dict
JSON-ready values: the status and its reason, the faces, the launch
energy and each face's launch baseline, largest judged and raw shares,
the noise allowance, the ramp's removal (judged and raw) and a collar's,
the reads and the largest gap between them, and where it first warned.
"""
return {
"launch_energy": self._launch if math.isfinite(self._launch) else None,
"noise_ramp_removed": self._ramp_noise,
"noise_share_in_ramp": self._noise_share_in_ramp,
"declared_share_in_ramp": self._declared_share,
"transverse_status": self._status,
"transverse_reason": self._reason,
"faces": [face.name for face in self._faces],
"edge_baseline": list(self._baseline),
"edge_max": list(self._peak),
"edge_max_raw": list(self._peak_raw),
"noise_allowance": self._allowance,
"ramp_removed": self.ramp_removed,
"raw_ramp_removed": self._ramp_raw,
"spatial_removed": self._spatial_removed,
"reads": self._reads,
"returned_max": self._returned_peak,
"strip_max": self._strip_peak,
"strip_baseline": self._strip_baseline,
"current": self._current,
"current_baseline": self._current_baseline,
"largest_gap": self._largest_gap,
"warned_at": None if math.isnan(self._warned_at) else self._warned_at,
}
[docs]
def finish(self) -> None:
"""Log the edge's verdict at the end of a run."""
logger.info(
"transverse edge: %s; largest judged share %.2e; the ramp removed %.2e; "
"%d reads",
self._status,
max(self._peak, default=0.0),
self.ramp_removed,
self._reads,
)