From e0d27ae0973644d2972d50bbc98a10ca4204b0b7 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:41:28 +0000 Subject: [PATCH 01/42] Merge the identical glass and Sellmeier omega checks into _optmat Co-Authored-By: Claude Opus 5.5 --- src/solprop/physdata/_optmat.py | 25 ++++++++++++++++++++++++ src/solprop/physdata/glasses.py | 32 ++++--------------------------- src/solprop/physdata/sellmeier.py | 30 +++-------------------------- 3 files changed, 32 insertions(+), 55 deletions(-) diff --git a/src/solprop/physdata/_optmat.py b/src/solprop/physdata/_optmat.py index a5f1d7db..02f4535c 100644 --- a/src/solprop/physdata/_optmat.py +++ b/src/solprop/physdata/_optmat.py @@ -51,6 +51,7 @@ import numpy as np from jaxtyping import Float +from numpy.typing import ArrayLike from solprop.constants import BAR, C from solprop.exceptions import ( @@ -108,6 +109,30 @@ def omega_to_wavelength( return np.asarray(np.where(values > 0, 2 * math.pi * C / values, math.inf)) +def checked_omega(omega: ArrayLike | float | int) -> Float[np.ndarray, "*shape"]: + """Validate an angular-frequency argument and return it as a float array. + + - ``omega``: angular frequency in rad/s, >= 0 and finite. A scalar, a sequence or an + array. + """ + # numpy casts a complex argument to float with a warning rather than an error, so + # it is rejected here before the conversion. + if np.iscomplexobj(omega): + raise MaterialError("angular frequency must be real, got a complex value") + try: + values = np.asarray(omega, dtype=float) + except (TypeError, ValueError) as exc: + raise MaterialError(f"angular frequency must be real: {exc}") from exc + if not np.all(np.isfinite(values)): + raise MaterialError("angular frequency must be finite") + if np.any(values < 0.0): + raise MaterialError( + "angular frequency must not be negative; a refractive index is not defined " + "there. Evaluate on the physical band of the grid." + ) + return values + + def wavelength_to_omega( wavelength: Float[np.ndarray, "*shape"], ) -> Float[np.ndarray, "*shape"]: diff --git a/src/solprop/physdata/glasses.py b/src/solprop/physdata/glasses.py index d7d7c418..fb16cdff 100644 --- a/src/solprop/physdata/glasses.py +++ b/src/solprop/physdata/glasses.py @@ -79,30 +79,6 @@ """Factor `k` with ``lambda[um] = k / omega``.""" -def _as_omega(omega: ArrayLike | float | int) -> Float[np.ndarray, "*shape"]: - """Validate an angular-frequency argument and return it as a float array. - - - ``omega``: angular frequency in rad/s, >= 0 and finite. A scalar, a sequence or an - array. - """ - # numpy casts a complex argument to float with a warning rather than an error, so - # it is rejected here before the conversion. - if np.iscomplexobj(omega): - raise MaterialError("angular frequency must be real, got a complex value") - try: - values = np.asarray(omega, dtype=float) - except (TypeError, ValueError) as exc: - raise MaterialError(f"angular frequency must be real: {exc}") from exc - if not np.all(np.isfinite(values)): - raise MaterialError("angular frequency must be finite") - if np.any(values < 0.0): - raise MaterialError( - "angular frequency must not be negative; a refractive index is not defined " - "there. Evaluate on the physical band of the grid." - ) - return values - - @dataclass(frozen=True, kw_only=True, eq=False) class GlassIndex(ABC): """A complex refractive-index model for one glass from one source. @@ -185,7 +161,7 @@ def index(self, omega: ArrayLike | float | int) -> Complex[np.ndarray, "*shape"] >>> f"{complex(silica.index(2.356e15)):.6f}" '1.453722+0.000000j' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) return self._index_and_derivative(values)[0] @@ -217,7 +193,7 @@ def dindex_domega( >>> f"{complex(silica.dindex_domega(2.356e15)):.4e}" '5.8781e-18+0.0000e+00j' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) return self._index_and_derivative(values)[1] @@ -248,7 +224,7 @@ def permittivity( >>> f"{complex(silica.permittivity(2.356e15)):.6f}" '2.113309+0.000000j' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) index, _ = self._index_and_derivative(values) return np.asarray(index**2) @@ -280,7 +256,7 @@ def dpermittivity_domega( >>> f"{complex(silica.dpermittivity_domega(2.356e15)):.4e}" '1.7090e-17+0.0000e+00j' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) index, derivative = self._index_and_derivative(values) return np.asarray(2.0 * index * derivative) diff --git a/src/solprop/physdata/sellmeier.py b/src/solprop/physdata/sellmeier.py index 70af3947..5a2685fe 100644 --- a/src/solprop/physdata/sellmeier.py +++ b/src/solprop/physdata/sellmeier.py @@ -106,30 +106,6 @@ def _reference_density(gas: Gas, state: ReferenceState) -> float: return density(gas, state.pressure_bar, state.temperature) -def _as_omega(omega: ArrayLike | float | int) -> Float[np.ndarray, "*shape"]: - """Validate an angular-frequency argument and return it as a float array. - - - ``omega``: angular frequency in rad/s, >= 0 and finite. A scalar, a sequence or an - array. - """ - # numpy casts a complex argument to float with a warning rather than an error, so - # it is rejected here before the conversion. - if np.iscomplexobj(omega): - raise MaterialError("angular frequency must be real, got a complex value") - try: - values = np.asarray(omega, dtype=float) - except (TypeError, ValueError) as exc: - raise MaterialError(f"angular frequency must be real: {exc}") from exc - if not np.all(np.isfinite(values)): - raise MaterialError("angular frequency must be finite") - if np.any(values < 0.0): - raise MaterialError( - "angular frequency must not be negative; a refractive index is not defined " - "there. Evaluate on the physical band of the grid." - ) - return values - - def _wavelength_extremes(omega: Float[np.ndarray, "*shape"]) -> tuple[float, float]: """Return the shortest and longest wavelength of a set of angular frequencies. @@ -253,7 +229,7 @@ def susceptibility( >>> f"{float(model.susceptibility(2.356e15)):.6e}" '2.079791e-29' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) u = np.asarray((_INVERSE_MICRON_PER_OMEGA * values) ** 2) chi, _ = self._chi_and_derivative(u) @@ -288,7 +264,7 @@ def dsusceptibility_domega( >>> float(gamma(Gas.Ar).dsusceptibility_domega(2.356e15)) > 0.0 True """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) scale = _INVERSE_MICRON_PER_OMEGA _, dchi_du = self._chi_and_derivative(np.asarray((scale * values) ** 2)) @@ -665,7 +641,7 @@ def _evaluate( - ``omega``: angular frequency in rad/s, >= 0; zero is the static limit. - ``method``: ``"complex_polarizability"`` or ``"dpolarizability_domega"``. """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) wavelength = _optmat.omega_to_wavelength(values) evaluator = getattr(self.model, method) # Per particle, the polarizability does not depend on z, yet a translation From 5b7c2a15764ba810c3fb349caeb4fa0b7c4d7098 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:41:28 +0000 Subject: [PATCH 02/42] Add shared argument refusals in solprop._validation Co-Authored-By: Claude Opus 5.5 --- .../uniform-refusal-wording.changed.md | 5 + docs/dev/architecture.md | 4 +- src/solprop/_validation.py | 96 +++++++++++++++ src/solprop/gasflow.py | 35 ++---- src/solprop/modes/antiresonant.py | 28 ++--- src/solprop/nonlinear/tdse.py | 55 +++++---- src/solprop/plotting/propagation.py | 5 +- src/solprop/plotting/style.py | 8 +- src/solprop/processing/axes.py | 17 +-- src/solprop/processing/fields.py | 8 +- src/solprop/processing/spectra.py | 7 +- src/solprop/processing/time.py | 5 +- src/solprop/pulses.py | 54 ++++----- src/solprop/tools.py | 111 ++++++++---------- tests/driver/test_parameter_paths.py | 2 +- tests/driver/test_prop_planar.py | 2 +- tests/plotting/test_propagation.py | 9 +- tests/plotting/test_style.py | 8 +- tests/processing/test_axes.py | 13 +- tests/processing/test_fields.py | 8 +- tests/processing/test_noise_floor.py | 9 +- tests/processing/test_time.py | 9 +- tests/test_gasflow.py | 14 ++- tests/test_pulse_loaders.py | 2 +- tests/test_pulses.py | 24 ++-- tests/test_soliton_content.py | 4 +- tests/test_tools.py | 4 +- tests/test_validation.py | 58 +++++++++ 28 files changed, 371 insertions(+), 233 deletions(-) create mode 100644 changelog.d/uniform-refusal-wording.changed.md create mode 100644 src/solprop/_validation.py create mode 100644 tests/test_validation.py diff --git a/changelog.d/uniform-refusal-wording.changed.md b/changelog.d/uniform-refusal-wording.changed.md new file mode 100644 index 00000000..d728761b --- /dev/null +++ b/changelog.d/uniform-refusal-wording.changed.md @@ -0,0 +1,5 @@ +The refusals of a non-positive or non-finite number and of an unknown keyword now read +the same across the design tools, pulses, gas flow, antiresonant tubes, the TDSE +specifications and the processing and plotting functions: ` must be finite and +positive, got ` and ` must be 'a', 'b' or 'c', got `. A numeric +string given where those functions expect a number is now refused rather than parsed. diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index b265d456..c480060a 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -242,7 +242,9 @@ directly, without a contract, a backend or an output handler, which is what make cross-check of the reference backend. `processing/windows.py` is a candidate module, drawn dashed in figure 1, to be written only for an agreed use case. `_display.py` is private: the formatter behind every class's printed block, imported by -any layer and importing none. +any layer and importing none. `_validation.py` is private too: the plain refusals +(`positive`, `finite`, `choice`, `shape`) in one wording, each raising the caller's own +exception class, imported by any layer and importing only `exceptions.py`. The layout does not mirror Luna's module tree: what `NonlinearRHS.jl` does spans response normalisation, translation and backend passes; `Maths.jl` mostly becomes SciPy diff --git a/src/solprop/_validation.py b/src/solprop/_validation.py new file mode 100644 index 00000000..2562df1c --- /dev/null +++ b/src/solprop/_validation.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Refuse an argument by name, in one wording across the package. + +Each check takes the value, the name the message gives it and the exception class of +the calling layer, so a refusal reads the same wherever it is raised and is still the +caller's own error. Bespoke physics checks stay where they are; these are only the +plain conditions that recur everywhere. + +This module imports nothing from the rest of the package except the exception +hierarchy, so that any module may use it. +""" + +import math +from collections.abc import Sequence + +from solprop.exceptions import ParameterPath, SolpropError + +__all__ = ["choice", "finite", "positive", "shape"] + + +def positive( + value: float, + name: str, + error: type[SolpropError], + *, + parameter: ParameterPath | None = None, + reject_bool: bool = False, +) -> float: + """Return a finite, strictly positive number as a float, or refuse it by name. + + A string is refused rather than parsed. + + - ``parameter``: the recipe path the error carries, where the caller has one. + - ``reject_bool``: also refuse ``True``, which would otherwise count as 1. + """ + refused = isinstance(value, str | bytes) or ( + reject_bool and isinstance(value, bool) + ) + number = math.nan if refused else float(value) + if not (math.isfinite(number) and number > 0.0): + raise error( + f"{name} must be finite and positive, got {value!r}", parameter=parameter + ) + return number + + +def finite( + value: float, + name: str, + error: type[SolpropError], + *, + parameter: ParameterPath | None = None, +) -> float: + """Return a finite number as a float, or refuse it by name; a string is refused.""" + number = math.nan if isinstance(value, str | bytes) else float(value) + if not math.isfinite(number): + raise error(f"{name} must be finite, got {value!r}", parameter=parameter) + return number + + +def choice( + value: object, + name: str, + choices: Sequence[object], + error: type[SolpropError], + *, + parameter: ParameterPath | None = None, +) -> None: + """Refuse a keyword outside its documented values, listing them. + + The annotations already say which values are allowed; this is the check a caller + without a type checker meets. + """ + if value not in choices: + *head, last = (repr(option) for option in choices) + allowed = f"{', '.join(head)} or {last}" if head else last + raise error(f"{name} must be {allowed}, got {value!r}", parameter=parameter) + + +def shape( + actual: tuple[int, ...], + expected: tuple[int, ...], + name: str, + error: type[SolpropError], + *, + parameter: ParameterPath | None = None, +) -> None: + """Refuse an array whose shape is not the one expected.""" + if tuple(actual) != tuple(expected): + raise error( + f"{name} must have shape {tuple(expected)}, got {tuple(actual)}", + parameter=parameter, + ) diff --git a/src/solprop/gasflow.py b/src/solprop/gasflow.py index dca1f583..f10f9e6b 100644 --- a/src/solprop/gasflow.py +++ b/src/solprop/gasflow.py @@ -66,6 +66,7 @@ from scipy.optimize import brentq from solprop._display import Displayed, engineering, line +from solprop._validation import choice, positive from solprop.constants import BAR, K_B, N_A, ROOM_TEMPERATURE, STD_ATMOSPHERE from solprop.exceptions import GasFlowWarning, MaterialError, TranslationError from solprop.physdata.mixtures import Mixture @@ -406,24 +407,6 @@ def values(temperatures: Float[np.ndarray, " n_t"]) -> Float[np.ndarray, " n_t"] ) -def _positive(value: float, what: str) -> float: - """Return a finite positive number, or refuse it by name.""" - number = float(value) - if not (math.isfinite(number) and number > 0.0): - raise TranslationError(f"{what} must be finite and positive, got {value}") - return number - - -def _checked_choice(value: object, name: str, choices: tuple[str, ...]) -> None: - """Refuse, by name, a keyword outside its literal choices. - - The annotations already say which strings are allowed; this is the check a caller - without a type checker meets. - """ - if value not in choices: - raise TranslationError(f"{name} must be one of {choices}, got {value!r}") - - def _choked_from(high_pa: float, factor: float) -> float: """Solve ``k (p_h^2 - p_e^2) = p_e`` for the exit pressure without cancellation. @@ -461,7 +444,7 @@ def _resistance(radius: float | ZProfile, length: float) -> tuple[ZProfile, floa - Returns: the radius as a path, and ``int_0^length a^-4 dz`` in m^-3. """ path = as_profile(radius) - edges = np.array([0.0, _positive(length, "length in m")]) + edges = np.array([0.0, positive(length, "length in m", TranslationError)]) return path, float(hydraulic_integral(path, edges)[0]) @@ -589,7 +572,7 @@ def choked_pressure( >>> f"{choked_pressure('Ar', 15e-6, 1.0, 20.0) * 1e3:.1f}" '79.1' """ - _checked_choice(exit_end, "exit_end", ("start", "end")) + choice(exit_end, "exit_end", ("start", "end"), TranslationError) flow = _FlowGas.of(gas, temperature) path, resistance = _resistance(radius, length) position = 0.0 if exit_end == "start" else float(length) @@ -818,7 +801,9 @@ def chamber_pressure( """ rate = _checked_pressure(throughput, "throughput") if not callable(pump_speed): - return rate / _positive(pump_speed, "pump speed in m^3/s") / BAR + return ( + rate / positive(pump_speed, "pump speed in m^3/s", TranslationError) / BAR + ) if rate == 0.0: return 0.0 excess = functools.partial(_pump_excess, pump_speed=pump_speed, rate=rate) @@ -841,7 +826,7 @@ def _pump_excess( - ``rate``: the gas throughput in Pa m^3 s^-1. - Returns: ``p S(p) - Q`` in Pa m^3 s^-1. """ - speed = _positive(pump_speed(pressure_bar), "pump speed in m^3/s") + speed = positive(pump_speed(pressure_bar), "pump speed in m^3/s", TranslationError) return pressure_bar * BAR * speed - rate @@ -899,7 +884,9 @@ def standard_litres_per_minute( >>> f"{standard_litres_per_minute(1.0, 273.15):.4f}" '0.5922' """ - scale = STANDARD_TEMPERATURE / _positive(temperature, "temperature in K") + scale = STANDARD_TEMPERATURE / positive( + temperature, "temperature in K", TranslationError + ) return float(throughput) * scale / STANDARD_PRESSURE * 1000.0 * 60.0 @@ -1573,7 +1560,7 @@ def steady_fill( >>> f"{fill.pressures[-1] * 1e3:.2f} mbar" '41.18 mbar' """ - _checked_choice(vacuum_end, "vacuum_end", get_args(VacuumEnd)) + choice(vacuum_end, "vacuum_end", get_args(VacuumEnd), TranslationError) edges = _checked_edges(positions, "steady_fill") declared = _checked_pressures(edges, pressures_bar) path = as_profile(radius) diff --git a/src/solprop/modes/antiresonant.py b/src/solprop/modes/antiresonant.py index a03764a2..a8491e23 100644 --- a/src/solprop/modes/antiresonant.py +++ b/src/solprop/modes/antiresonant.py @@ -24,6 +24,7 @@ from jaxtyping import Float, Inexact from solprop._display import Displayed, engineering, line +from solprop._validation import positive from solprop.constants import C from solprop.exceptions import ModeError from solprop.modes.capillary import MarcatiliMode, MarcatiliTerms @@ -144,15 +145,6 @@ class _Conventions: LEGACY_CONVENTIONS: Final = _Conventions(20.0, False, False, False) -def _positive(value: float, name: str) -> None: - """Check a positive finite dimension. - - - ``value``: dimension in m. - """ - if not math.isfinite(value) or value <= 0: - raise ModeError(f"{name} must be positive and finite, got {value}") - - def _tube_angle(n_tubes: int) -> float: """Return the sine of half the angular tube spacing. @@ -185,8 +177,8 @@ def tube_gap(core_radius: float, tube_radius: float, n_tubes: int) -> float: ModeError If a radius is not positive and finite or the count is invalid. """ - _positive(core_radius, "core_radius") - _positive(tube_radius, "tube_radius") + positive(core_radius, "core_radius", ModeError) + positive(tube_radius, "tube_radius", ModeError) return 2 * ((core_radius + tube_radius) * _tube_angle(n_tubes) - tube_radius) @@ -212,7 +204,7 @@ def core_radius_from_gap(tube_radius: float, n_tubes: int, gap: float) -> float: ModeError If the geometry is invalid. """ - _positive(tube_radius, "tube_radius") + positive(tube_radius, "tube_radius", ModeError) _check_gap(gap) return (tube_radius + gap / 2) / _tube_angle(n_tubes) - tube_radius @@ -239,11 +231,11 @@ def tube_radius_from_gap(core_radius: float, n_tubes: int, gap: float) -> float: ModeError If the geometry is invalid. """ - _positive(core_radius, "core_radius") + positive(core_radius, "core_radius", ModeError) _check_gap(gap) sine = _tube_angle(n_tubes) radius = (core_radius * sine - gap / 2) / (1 - sine) - _positive(radius, "tube_radius implied by gap") + positive(radius, "tube_radius implied by gap", ModeError) return radius @@ -315,8 +307,8 @@ def __post_init__(self) -> None: If dimensions or counts cannot describe hollow tubes, or more fit terms are asked for than the cutoff fits support. """ - _positive(self.wall_thickness, "wall_thickness") - _positive(self.tube_radius, "tube_radius") + positive(self.wall_thickness, "wall_thickness", ModeError) + positive(self.tube_radius, "tube_radius", ModeError) _tube_angle(self.n_tubes) if self.wall_thickness >= self.tube_radius: raise ModeError("wall_thickness must be smaller than tube_radius") @@ -379,8 +371,8 @@ def from_inner_diameter_ratio( ModeError If dimensions are invalid or neighbouring tubes overlap. """ - _positive(core_radius, "core_radius") - _positive(inner_diameter_ratio, "inner_diameter_ratio") + positive(core_radius, "core_radius", ModeError) + positive(inner_diameter_ratio, "inner_diameter_ratio", ModeError) lattice = cls( wall_thickness, core_radius * inner_diameter_ratio + wall_thickness, n_tubes ) diff --git a/src/solprop/nonlinear/tdse.py b/src/solprop/nonlinear/tdse.py index fd2bfa33..eebd9101 100644 --- a/src/solprop/nonlinear/tdse.py +++ b/src/solprop/nonlinear/tdse.py @@ -20,6 +20,7 @@ from dataclasses import KW_ONLY, asdict, dataclass, field from typing import Literal +from solprop._validation import positive from solprop.constants import AU_TIME from solprop.exceptions import MaterialError from solprop.nonlinear.responses import ResponseTraits @@ -57,12 +58,6 @@ ) -def _positive(value: float, name: str) -> None: - """Require a finite positive real scalar, excluding booleans.""" - if isinstance(value, bool) or not math.isfinite(value) or value <= 0: - raise MaterialError(f"{name} must be positive and finite") - - def _count(value: int, name: str, minimum: int = 1) -> None: """Require a plain integer above the declared lower bound.""" if type(value) is not int or value < minimum: @@ -152,7 +147,7 @@ def __post_init__(self) -> None: _count(self.lmax, "lmax", 0) if self.lmax > _MAX_ANGULAR_ORDER or type(self.m) is not int or self.m != 0: raise MaterialError("TDSE helium requires m=0 and lmax <= 4096") - _positive(self.cut_energy, "cut_energy") + positive(self.cut_energy, "cut_energy", MaterialError, reject_bool=True) self._validate_radial() self._validate_scaling() boundary = ( @@ -164,7 +159,7 @@ def __post_init__(self) -> None: raise MaterialError("cuts must be a tuple") previous = 0.0 for cut in self.cuts: - _positive(cut, "cut") + positive(cut, "cut", MaterialError, reject_bool=True) if cut not in self.radial_edges or not previous < cut < boundary: raise MaterialError( "cuts must be increasing radial edges inside the boundary" @@ -196,9 +191,9 @@ def _validate_scaling(self) -> None: raise MaterialError("all four scaling fields must be given together") assert self.scaling_radius is not None and self.scaling_angle is not None assert self.infinite_order is not None and self.decay is not None - _positive(self.scaling_radius, "scaling_radius") - _positive(self.scaling_angle, "scaling_angle") - _positive(self.decay, "decay") + positive(self.scaling_radius, "scaling_radius", MaterialError, reject_bool=True) + positive(self.scaling_angle, "scaling_angle", MaterialError, reject_bool=True) + positive(self.decay, "decay", MaterialError, reject_bool=True) _count(self.infinite_order, "infinite_order") if self.scaling_radius not in self.radial_edges: raise MaterialError("scaling_radius must exactly equal a radial edge") @@ -236,7 +231,7 @@ def _radial_axis(edges: Sequence[float], orders: Sequence[int]) -> None: raise MaterialError("the first radial edge must be zero") previous = 0.0 for edge in edges[1:]: - _positive(edge, "radial edge") + positive(edge, "radial edge", MaterialError, reject_bool=True) if edge <= previous: raise MaterialError("radial_edges must be strictly increasing") previous = edge @@ -347,7 +342,7 @@ def __post_init__(self) -> None: raise MaterialError("angular_order must be <= 120") self._validate_scaling() if self.gauge_radius is not None: - _positive(self.gauge_radius, "gauge_radius") + positive(self.gauge_radius, "gauge_radius", MaterialError, reject_bool=True) if ( self.gauge_radius not in self.radial_edges or not self.gauge_radius < self.scaling_radius @@ -355,8 +350,8 @@ def __post_init__(self) -> None: raise MaterialError( "gauge_radius must be a radial edge strictly inside scaling_radius" ) - _positive(self.cut_energy, "cut_energy") - _positive(self.max_step_au, "max_step_au") + positive(self.cut_energy, "cut_energy", MaterialError, reject_bool=True) + positive(self.max_step_au, "max_step_au", MaterialError, reject_bool=True) _text(self.source, "basis source") if self.preset is not None: match = ( @@ -395,9 +390,9 @@ def _validate_scaling(self) -> None: ) if any(value is None for value in values): raise MaterialError("all four irECS fields must be given") - _positive(self.scaling_radius, "scaling_radius") - _positive(self.scaling_angle, "scaling_angle") - _positive(self.decay, "decay") + positive(self.scaling_radius, "scaling_radius", MaterialError, reject_bool=True) + positive(self.scaling_angle, "scaling_angle", MaterialError, reject_bool=True) + positive(self.decay, "decay", MaterialError, reject_bool=True) _count(self.infinite_order, "infinite_order") if self.scaling_radius not in self.radial_edges[1:]: raise MaterialError("scaling_radius must exactly equal a radial edge") @@ -495,10 +490,12 @@ class TDSEAtomicWindow: def __post_init__(self) -> None: """Validate the duration and taper before any grid is known.""" - _positive(self.taper, "atomic window taper") + positive(self.taper, "atomic window taper", MaterialError, reject_bool=True) object.__setattr__(self, "taper", float(self.taper)) if self.duration is not None: - _positive(self.duration, "atomic window duration") + positive( + self.duration, "atomic window duration", MaterialError, reject_bool=True + ) object.__setattr__(self, "duration", float(self.duration)) if not self.taper < self.duration / 2: raise MaterialError( @@ -538,8 +535,8 @@ class TDSEConsistency: def __post_init__(self) -> None: """Validate the bounds and the switch.""" - _positive(self.warn, "consistency warn") - _positive(self.refuse, "consistency refuse") + positive(self.warn, "consistency warn", MaterialError, reject_bool=True) + positive(self.refuse, "consistency refuse", MaterialError, reject_bool=True) if self.warn > self.refuse: raise MaterialError("consistency warn must not exceed refuse") if type(self.enforce) is not bool: @@ -589,7 +586,9 @@ def __post_init__(self) -> None: if type(self.wavelength_range) is not tuple or len(self.wavelength_range) != 2: raise MaterialError("wavelength_range must be a pair") for wavelength in self.wavelength_range: - _positive(wavelength, "calibration wavelength") + positive( + wavelength, "calibration wavelength", MaterialError, reject_bool=True + ) if self.wavelength_range[0] >= self.wavelength_range[1]: raise MaterialError("wavelength_range must be increasing") _strings(self.limitations, "limitations") @@ -737,8 +736,12 @@ class TDSEGasResponse: def __post_init__(self) -> None: """Admit the declared source closures without hidden rescaling.""" self._validate_records() - _positive(self.max_atomic_step, "max_atomic_step") - _positive(self.remainder_scale, "remainder_scale") + positive( + self.max_atomic_step, "max_atomic_step", MaterialError, reject_bool=True + ) + positive( + self.remainder_scale, "remainder_scale", MaterialError, reject_bool=True + ) if self.kerr_model not in ("optmat_cubic", "tdse_shadow", "tdse_native"): raise MaterialError("unknown TDSE Kerr model") if isinstance(self.basis, HaccBasis): @@ -748,7 +751,7 @@ def __post_init__(self) -> None: if self.kerr_model == "tdse_shadow": if self.cubic_scale is None: raise MaterialError("tdse_shadow requires cubic_scale") - _positive(self.cubic_scale, "cubic_scale") + positive(self.cubic_scale, "cubic_scale", MaterialError, reject_bool=True) elif self.cubic_scale is not None: raise MaterialError("cubic_scale is available only for tdse_shadow") self._resolve_window() diff --git a/src/solprop/plotting/propagation.py b/src/solprop/plotting/propagation.py index 6f9261d2..484d8eb7 100644 --- a/src/solprop/plotting/propagation.py +++ b/src/solprop/plotting/propagation.py @@ -28,6 +28,8 @@ from matplotlib.colors import Colormap from matplotlib.figure import Figure +from solprop._validation import choice +from solprop.exceptions import ProcessingError from solprop.optics import TransferFunction from solprop.output.schema import Output from solprop.plotting.style import ( @@ -48,7 +50,6 @@ from solprop.processing.axes import ( ModeSelection, SpectralAxis, - _check_choice, _refuse_by_basis, ) from solprop.processing.fields import ( @@ -290,7 +291,7 @@ def time_1d( >>> plt.close(figure) """ _refuse_by_basis(output, "time_1d") - _check_choice(quantity, SLICE_QUANTITIES, "quantity") + choice(quantity, "quantity", SLICE_QUANTITIES, ProcessingError) ax = _axis(ax, (8.5, 5.0)) planes = _plane_indices(output, z) traces = [] diff --git a/src/solprop/plotting/style.py b/src/solprop/plotting/style.py index cd9febfb..aeb8a03c 100644 --- a/src/solprop/plotting/style.py +++ b/src/solprop/plotting/style.py @@ -24,13 +24,13 @@ from matplotlib.colors import Colormap, LinearSegmentedColormap from matplotlib.figure import Figure +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.output.schema import Output, PlaneStore from solprop.processing.axes import ( SPECTRAL_AXES, SpectralAxis, - _check_choice, transverse_units, ) from solprop.processing.time import half_max_range @@ -301,7 +301,7 @@ def spectral_display(axis: SpectralAxis) -> tuple[float, str]: >>> spectral_display("wavelength") (1000000000.0, 'Wavelength (nm)') """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) if axis == "wavelength": return 1e9, "Wavelength (nm)" if axis == "f": @@ -342,7 +342,7 @@ def wavelength_to_axis( >>> f"{low:.4g} Hz, {high:.4g} Hz" '1.499e+14 Hz, 7.495e+14 Hz' """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) low, high = min(wavelength_limits), max(wavelength_limits) if axis == "wavelength": return low, high @@ -408,7 +408,7 @@ def corner_text( >>> corner_text(ax, "a)") >>> plt.close(figure) """ - _check_choice(corner, CORNERS, "corner") + choice(corner, "corner", CORNERS, ProcessingError) upper, left = corner[0] == "u", corner[1] == "l" ax.text( pad if left else 1.0 - pad, diff --git a/src/solprop/processing/axes.py b/src/solprop/processing/axes.py index 0faee6ae..bef18409 100644 --- a/src/solprop/processing/axes.py +++ b/src/solprop/processing/axes.py @@ -26,6 +26,7 @@ from jaxtyping import Bool, Complex, Float, Inexact, Int from solprop import fourier +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ModeError, OutputError, ProcessingError from solprop.grid import ( @@ -413,7 +414,7 @@ def band_mask(grid: TimeGrid, band: Band = "active") -> Bool[np.ndarray, " n_ome >>> int(band_mask(grid, "all").sum()) == grid.n_omega True """ - _check_choice(band, BANDS, "band") + choice(band, "band", BANDS, ProcessingError) if band == "active": return grid.active_mask return np.ones(grid.n_omega, dtype=bool) @@ -462,7 +463,7 @@ def spectral_axis( >>> float(x[0]) / 1e14, float(jacobian[0]) (3.183098861837907, 6.283185307179586) """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) if axis == "omega": return omega, np.ones_like(omega) if axis == "f": @@ -651,18 +652,6 @@ def _squeeze( return values -def _check_choice(value: str, allowed: tuple[str, ...], what: str) -> None: - """Reject a keyword that is not one of a fixed set of names. - - The parameter is a plain string rather than the enumeration itself, exactly as - :func:`solprop.fourier._check_kind` is, so that the refusal stays reachable in - production, where no runtime type checker stands in front of it. - """ - if value not in allowed: - msg = f"{what} must be one of {allowed}, got {value!r}" - raise ProcessingError(msg) - - def _check_mode_selection(modes: object) -> None: """Reject a mode selection that is none of the four forms. diff --git a/src/solprop/processing/fields.py b/src/solprop/processing/fields.py index 64266e25..50fbf46f 100644 --- a/src/solprop/processing/fields.py +++ b/src/solprop/processing/fields.py @@ -48,6 +48,7 @@ from jaxtyping import Complex, Float, Inexact from solprop import fourier +from solprop._validation import choice from solprop.exceptions import ModeError, ProcessingError from solprop.grid import TimeGrid from solprop.optics import TransferFunction, stored_factor @@ -59,7 +60,6 @@ ModeSelection, SpectralAxis, _apply_modes, - _check_choice, _keep_index, _nearest_plane, _plane_field, @@ -329,7 +329,7 @@ def _spectral_density( - ``z``: position of the plane wanted, m. - ``band``: which part of the stored axis to use. """ - _check_choice(noise_floor, NOISE_FLOORS, "noise_floor") + choice(noise_floor, "noise_floor", NOISE_FLOORS, ProcessingError) grid = grid_of(output) values = _plane_field(output, plane, z, convention="optics") density = np.abs(values * _fft_norm(grid)) ** 2 @@ -467,7 +467,7 @@ def get_field( >>> bool(np.isrealobj(field)) True """ - _check_choice(kind, FIELD_KINDS, "kind") + choice(kind, "kind", FIELD_KINDS, ProcessingError) grid, t, amplitude = _plane_amplitude( output, plane=plane, @@ -914,7 +914,7 @@ def get_spectrogram( >>> values.shape == (f.size, t.size), bool(np.all(np.diff(f) > 0)) (True, True) """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) if plane is None and z is None: plane = -1 t, field = get_field( diff --git a/src/solprop/processing/spectra.py b/src/solprop/processing/spectra.py index d2a5a5d9..97f1cbac 100644 --- a/src/solprop/processing/spectra.py +++ b/src/solprop/processing/spectra.py @@ -19,10 +19,11 @@ import numpy as np from jaxtyping import Float +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.grid import TimeGrid, planck_taper -from solprop.processing.axes import SPECTRAL_AXES, SpectralAxis, _check_choice +from solprop.processing.axes import SPECTRAL_AXES, SpectralAxis __all__ = [ "DEFAULT_BANDPASS_BINS", @@ -75,7 +76,7 @@ class Passband: def __post_init__(self) -> None: """Refuse an unknown axis, and limits that name no light on it.""" - _check_choice(self.axis, SPECTRAL_AXES, "axis") + choice(self.axis, "axis", SPECTRAL_AXES, ProcessingError) _check_limits(self.spec_range, self.axis) if self.width is not None: _check_width(self.width) @@ -161,7 +162,7 @@ def bandpass( >>> bool(np.allclose(window, same)) True """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) _check_limits(spec_range, axis) if axis == "wavelength": edges = sorted(2.0 * np.pi * C / limit for limit in spec_range) diff --git a/src/solprop/processing/time.py b/src/solprop/processing/time.py index d67deaf4..e209c471 100644 --- a/src/solprop/processing/time.py +++ b/src/solprop/processing/time.py @@ -40,13 +40,14 @@ from jaxtyping import Float, Inexact from solprop import stats +from solprop._validation import choice +from solprop.exceptions import ProcessingError from solprop.grid import TimeGrid from solprop.output.schema import Output from solprop.processing import spectra from solprop.processing.axes import ( ModeSelection, _apply_modes, - _check_choice, _check_mode_index, _check_mode_selection, _plane_field, @@ -573,7 +574,7 @@ def arrival_time( True """ _refuse_by_basis(output, "arrival_time") - _check_choice(method, ARRIVAL_METHODS, "method") + choice(method, "method", ARRIVAL_METHODS, ProcessingError) t, power = _power(output, plane=plane, z=z, modes=modes, bandpass=bandpass) if method == "peak": return _along_last(power, lambda trace: float(t[np.argmax(trace)])) diff --git a/src/solprop/pulses.py b/src/solprop/pulses.py index e1d647bd..0d09be90 100644 --- a/src/solprop/pulses.py +++ b/src/solprop/pulses.py @@ -44,6 +44,7 @@ from solprop import fourier from solprop._display import Displayed, engineering, line, phase_text, summarise from solprop._process import parallel_rows +from solprop._validation import choice, positive from solprop.constants import EPS0, C from solprop.exceptions import PulseError, SolpropWarning from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid @@ -206,7 +207,7 @@ class SpatialPulse(Displayed): def __post_init__(self) -> None: """Validate the beam specification before sampling any grids.""" - _check_positive("waist", self.waist) + positive(self.waist, "waist", PulseError, parameter="waist") if ( isinstance(self.order, bool) or not isinstance(self.order, int) @@ -942,7 +943,7 @@ class PlanarPulse(SpatialPulse): def __post_init__(self) -> None: """Validate the beam and refuse what the planar launch does not carry.""" super().__post_init__() - _check_positive("waist_x", self.waist_x) + positive(self.waist_x, "waist_x", PulseError, parameter="waist_x") if not math.isfinite(self.offset_x): msg = "planar offset_x must be finite" raise PulseError(msg) @@ -1145,13 +1146,6 @@ def _incident_admittance( # noqa: PLR0917 - called positionally # --- shared validation and machinery -------------------------------------------------- -def _check_positive(name: str, value: float) -> None: - """Reject a quantity that is not finite and strictly positive.""" - if not math.isfinite(value) or value <= 0.0: - msg = f"{name} must be a finite positive number, got {value!r}" - raise PulseError(msg, parameter=name) - - def _scaling(energy: float | None, peak_power: float | None) -> tuple[float, _Quantity]: """Return the target and which quantity it is, rejecting an ambiguous request. @@ -1170,14 +1164,14 @@ def _scaling(energy: float | None, peak_power: float | None) -> tuple[float, _Qu raise PulseError( msg, parameter=("energy", "peak_power"), rule="energy-or-peak-power" ) - _check_positive("energy", energy) + positive(energy, "energy", PulseError, parameter="energy") return energy, "energy" if peak_power is None: msg = "one of energy or peak_power must be given, got neither" raise PulseError( msg, parameter=("energy", "peak_power"), rule="energy-or-peak-power" ) - _check_positive("peak_power", peak_power) + positive(peak_power, "peak_power", PulseError, parameter="peak_power") return peak_power, "peak_power" @@ -1555,7 +1549,7 @@ def __post_init__(self) -> None: If the wavelength is not positive, the scaling request is ambiguous, or a phase coefficient is not finite. """ - _check_positive("wavelength", self.wavelength) + positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") _scaling(self.energy, self.peak_power) object.__setattr__(self, "phase", _frozen_phase(self.phase)) @@ -1865,7 +1859,7 @@ def __post_init__(self) -> None: If ``fwhm`` is not positive or ``m`` is less than one. """ super().__post_init__() - _check_positive("fwhm", self.fwhm) + positive(self.fwhm, "fwhm", PulseError, parameter="fwhm") if self.m < 1: msg = f"m must be a super-Gaussian order of at least 1, got {self.m!r}" raise PulseError(msg) @@ -2010,12 +2004,12 @@ def _sech_width(fwhm: float | None, width: float | None) -> float: f"fwhm={fwhm!r} and width={width!r}" ) raise PulseError(msg, parameter=("fwhm", "width"), rule="fwhm-or-width") - _check_positive("width", width) + positive(width, "width", PulseError, parameter="width") return width if fwhm is None: msg = "one of fwhm or width must be given, got neither" raise PulseError(msg, parameter=("fwhm", "width"), rule="fwhm-or-width") - _check_positive("fwhm", fwhm) + positive(fwhm, "fwhm", PulseError, parameter="fwhm") return fwhm * SECH_FWHM_TO_WIDTH @@ -2233,9 +2227,9 @@ def __post_init__(self) -> None: if omega[0] <= 0.0 or np.any(np.diff(omega) <= 0.0): msg = "omega must be strictly increasing and positive, in rad/s" raise PulseError(msg) - _check_positive("energy", self.energy) + positive(self.energy, "energy", PulseError, parameter="energy") if self.wavelength is not None: - _check_positive("wavelength", self.wavelength) + positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") omega.setflags(write=False) spectrum.setflags(write=False) object.__setattr__(self, "omega", omega) @@ -2593,9 +2587,9 @@ def __post_init__(self) -> None: if steps[0] <= 0.0 or not np.allclose(steps, steps[0], rtol=1e-9, atol=0.0): msg = "t must be uniformly spaced and increasing" raise PulseError(msg) - _check_positive("energy", self.energy) + positive(self.energy, "energy", PulseError, parameter="energy") if self.wavelength is not None: - _check_positive("wavelength", self.wavelength) + positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") times.setflags(write=False) values.setflags(write=False) object.__setattr__(self, "t", times) @@ -2912,7 +2906,7 @@ def _axis_factor(axis: str, axis_unit: str | None) -> float: - Returns: the angular frequency in rad/s of one unit of a frequency column, or the length in metres of one unit of a wavelength column. """ - _check_choice("axis", axis, ("frequency", "wavelength")) + choice(axis, "axis", ("frequency", "wavelength"), PulseError) units, default = ( (_FREQUENCY_UNITS, "Hz") if axis == "frequency" else (_WAVELENGTH_UNITS, "m") ) @@ -2925,14 +2919,6 @@ def _axis_factor(axis: str, axis_unit: str | None) -> float: return units[unit] -def _check_choice(name: str, value: str, choices: Sequence[str]) -> None: - """Reject a keyword that is not one of its documented values.""" - if value not in choices: - allowed = " or ".join(repr(choice) for choice in choices) - msg = f"{name} must be {allowed}, got {value!r}" - raise PulseError(msg) - - def _undersampled_share( steps: Float[np.ndarray, " n_steps"], power: Float[np.ndarray, " n_significant"] ) -> float: @@ -3153,9 +3139,9 @@ def load_spectrum( >>> float(np.ptp(pulse.spectral_phase)) 0.0 """ - _check_positive("energy", energy) - _check_choice("phase_unit", phase_unit, ("rad", "deg")) - _check_choice("phase_convention", phase_convention, ("optics", "fft")) + positive(energy, "energy", PulseError, parameter="energy") + choice(phase_unit, "phase_unit", ("rad", "deg"), PulseError) + choice(phase_convention, "phase_convention", ("optics", "fft"), PulseError) if not (math.isfinite(floor) and 0.0 <= floor < 1.0): msg = f"floor must be a share of the peak power in [0, 1), got {floor!r}" raise PulseError(msg) @@ -3277,8 +3263,10 @@ def __post_init__(self) -> None: If the wavelength or power is not positive, or the bandwidth is negative or not finite. """ - _check_positive("wavelength", self.wavelength) - _check_positive("average_power", self.average_power) + positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") + positive( + self.average_power, "average_power", PulseError, parameter="average_power" + ) if not math.isfinite(self.bandwidth) or self.bandwidth < 0.0: msg = ( "bandwidth must be a finite non-negative wavelength width in metres, " diff --git a/src/solprop/tools.py b/src/solprop/tools.py index ddb891cd..0159c349 100644 --- a/src/solprop/tools.py +++ b/src/solprop/tools.py @@ -43,7 +43,7 @@ from contextlib import contextmanager from dataclasses import dataclass from itertools import pairwise -from typing import Literal, cast +from typing import Literal, cast, get_args import numpy as np from jaxtyping import Float @@ -53,6 +53,7 @@ from solprop import spatial from solprop._display import Displayed, line +from solprop._validation import choice, positive from solprop.constants import EPS0, ROOM_TEMPERATURE, C from solprop.exceptions import ( DiluteScalingWarning, @@ -217,30 +218,13 @@ def _probing() -> Iterator[None]: yield -def _positive(name: str, value: float) -> float: - """Return a finite positive number or refuse it by name.""" - number = float(value) - if not math.isfinite(number) or number <= 0.0: - raise ToolsError(f"{name} must be finite and positive, got {value!r}") - return number - - def _omega(wavelength: float) -> float: """Return the angular frequency of a vacuum wavelength. - ``wavelength``: vacuum wavelength in m, > 0. - Returns: angular frequency in rad/s. """ - return 2.0 * math.pi * C / _positive("wavelength", wavelength) - - -def _check_shape(shape: str) -> None: - """Refuse a pulse shape the design numbers are not defined for. - - - ``shape``: the shape keyword. - """ - if shape not in ("sech", "gauss"): - raise ToolsError(f"shape must be 'sech' or 'gauss', got {shape!r}") + return 2.0 * math.pi * C / positive(wavelength, "wavelength", ToolsError) def pulse_width(fwhm: float, shape: PulseShape = "sech") -> float: @@ -272,8 +256,10 @@ def pulse_width(fwhm: float, shape: PulseShape = "sech") -> float: >>> f"{pulse_width(10e-15, 'gauss') * 1e15:.4f}" '6.0056' """ - _check_shape(shape) - return _positive("fwhm", fwhm) / (_SECH_WIDTH if shape == "sech" else _GAUSS_WIDTH) + choice(shape, "shape", get_args(PulseShape), ToolsError) + return positive(fwhm, "fwhm", ToolsError) / ( + _SECH_WIDTH if shape == "sech" else _GAUSS_WIDTH + ) def pulse_peak_power(energy: float, fwhm: float, shape: PulseShape = "sech") -> float: @@ -309,7 +295,7 @@ def pulse_peak_power(energy: float, fwhm: float, shape: PulseShape = "sech") -> """ width = pulse_width(fwhm, shape) integral = 2.0 if shape == "sech" else math.sqrt(math.pi) - return _positive("energy", energy) / (integral * width) + return positive(energy, "energy", ToolsError) / (integral * width) def pulse_energy(peak_power: float, fwhm: float, shape: PulseShape = "sech") -> float: @@ -343,7 +329,9 @@ def pulse_energy(peak_power: float, fwhm: float, shape: PulseShape = "sech") -> '1.0000' """ # The peak power is linear in the energy at a fixed width and shape. - return _positive("peak_power", peak_power) / pulse_peak_power(1.0, fwhm, shape) + return positive(peak_power, "peak_power", ToolsError) / pulse_peak_power( + 1.0, fwhm, shape + ) def dispersion_length(fwhm: float, beta2: float, shape: PulseShape = "sech") -> float: @@ -405,7 +393,10 @@ def nonlinear_length(peak_power: float, gamma: float) -> float: >>> nonlinear_length(1e9, 1e-9) 1.0 """ - return 1.0 / (_positive("gamma", gamma) * _positive("peak_power", peak_power)) + return 1.0 / ( + positive(gamma, "gamma", ToolsError) + * positive(peak_power, "peak_power", ToolsError) + ) def soliton_order( @@ -496,8 +487,8 @@ def soliton_peak_power( >>> f"{power / 1e9:.4f} {soliton_order(power, 10e-15, 1e-9, -8e-30):.4f}" '0.9943 2.0000' """ - return _positive("order", order) ** 2 / ( - _positive("gamma", gamma) * dispersion_length(fwhm, beta2, shape) + return positive(order, "order", ToolsError) ** 2 / ( + positive(gamma, "gamma", ToolsError) * dispersion_length(fwhm, beta2, shape) ) @@ -747,10 +738,10 @@ def self_focusing_power(wavelength: float, index: float, n2: float) -> float: >>> f"{self_focusing_power(800e-9, 1.0, 3.55e-25) / 1e9:.2f}" '267.17' """ - linear = _positive("index", index) - chi3 = chi3_from_n2(_positive("n2", n2), linear) + linear = positive(index, "index", ToolsError) + chi3 = chi3_from_n2(positive(n2, "n2", ToolsError), linear) return spatial.critical_power( - _positive("wavelength", wavelength), chi3, index=linear + positive(wavelength, "wavelength", ToolsError), chi3, index=linear ) @@ -864,8 +855,8 @@ def beam_params( power = spatial.critical_power( wavelength, chi3(gas, pressure=pressure, wavelength=wavelength), index=index ) - rayleigh = np.pi * index * _positive("waist", waist) ** 2 / wavelength - ratio = _positive("peak_power", peak_power) / power + rayleigh = np.pi * index * positive(waist, "waist", ToolsError) ** 2 / wavelength + ratio = positive(peak_power, "peak_power", ToolsError) / power collapse = None if ratio > 1.0: # (sqrt(r) - 0.852)^2 - 0.0219 vanishes at r = 1, so it is positive above it. @@ -920,9 +911,9 @@ def beam_through_lens( >>> f"{waist * 1e6:.1f} um at {distance:.4f} m" '50.9 um at 0.9999 m' """ - focal = _positive("focal_length", focal_length) - beam = _positive("radius", radius) - light = _positive("wavelength", wavelength) + focal = positive(focal_length, "focal_length", ToolsError) + beam = positive(radius, "radius", ToolsError) + light = positive(wavelength, "wavelength", ToolsError) ratio = light * focal / (np.pi * beam**2) factor = 1.0 / (1.0 + ratio**2) return float(light * focal / (np.pi * beam) * np.sqrt(factor)), float( @@ -1541,7 +1532,7 @@ def _capillary( - ``cladding``: cladding index; ``None`` is fused silica. """ return MarcatiliMode( - _positive("radius", radius), + positive(radius, "radius", ToolsError), mixture=mixture, n=n, m=m, @@ -1712,7 +1703,7 @@ def _target_order( f"got {', '.join(given) or 'none'}" ) if order is not None: - return _positive("soliton_order", order) + return positive(order, "soliton_order", ToolsError) if count is not None: if count < 1: raise ToolsError(f"soliton_count must be at least 1, got {count}") @@ -1721,7 +1712,7 @@ def _target_order( # middle, S = M: the pure M-soliton for sech, the farthest from either # threshold for a Gaussian. return count / _area_per_order(shape) - eta = _positive("largest_eigenvalue", cast("float", eigenvalue)) + eta = positive(cast("float", eigenvalue), "largest_eigenvalue", ToolsError) return eta + 0.5 if shape == "sech" else _gaussian_order(eta) @@ -1827,7 +1818,7 @@ def capillary_design( """ materials = MaterialSelection() if materials is None else materials _check_carrier(materials, wavelength) - _check_shape(shape) + choice(shape, "shape", get_args(PulseShape), ToolsError) order = _target_order(soliton_order, soliton_count, largest_eigenvalue, shape) pressure = pressure_zdw( radius, @@ -1924,7 +1915,7 @@ def gas_ratio( susceptibilities = [] for gas in (gas_a, gas_b): fill = _resolve_fill( - gas, _positive("pressure", pressure), temperature, materials + gas, positive(pressure, "pressure", ToolsError), temperature, materials ) dispersions.append(_bulk_beta2(_BulkGas(fill), omega)) susceptibilities.append(_fill_chi3(fill, wavelength)) @@ -1989,15 +1980,6 @@ def second(h: float) -> float: # -------------------------------------------------------------------- phase matching -def _check_phase(nonlinear_phase: str) -> None: - """Refuse a nonlinear phase model the phase matching does not define.""" - if nonlinear_phase not in ("largest", "supplied_power", "linear"): - raise ToolsError( - "nonlinear_phase must be 'largest', 'supplied_power' or 'linear', " - f"got {nonlinear_phase!r}" - ) - - @dataclass(frozen=True) class _PhasePulse: """Validate the pulse declaration before a phase-matching search.""" @@ -2010,8 +1992,13 @@ class _PhasePulse: def __post_init__(self) -> None: """Refuse incomplete or contradictory phase declarations.""" - _check_shape(self.shape) - _check_phase(self.nonlinear_phase) + choice(self.shape, "shape", get_args(PulseShape), ToolsError) + choice( + self.nonlinear_phase, + "nonlinear_phase", + get_args(NonlinearPhase), + ToolsError, + ) if (self.peak_power is None) != (self.gamma is None): raise ToolsError( "the nonlinear term needs both peak_power and gamma; " @@ -2027,16 +2014,16 @@ def __post_init__(self) -> None: if self.fwhm is not None: raise ToolsError("fwhm requires peak_power and gamma") return - _positive("peak_power", self.peak_power) + positive(self.peak_power, "peak_power", ToolsError) assert self.gamma is not None # The paired declaration was validated above. - _positive("gamma", self.gamma) + positive(self.gamma, "gamma", ToolsError) if self.nonlinear_phase == "largest": if self.fwhm is None: raise ToolsError( "the largest constituent needs fwhm; supply it or select " "nonlinear_phase='supplied_power'" ) - _positive("fwhm", self.fwhm) + positive(self.fwhm, "fwhm", ToolsError) elif self.fwhm is not None: raise ToolsError( "nonlinear_phase='supplied_power' does not use fwhm; omit it" @@ -2428,7 +2415,7 @@ def _pressure_root( - ``pressure_max``: upper end of the bracket in bar. - Returns: the root in bar. """ - top = _positive("pressure_max", pressure_max) + top = positive(pressure_max, "pressure_max", ToolsError) if top <= _PRESSURE_FLOOR: raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") with _probing(): @@ -2472,7 +2459,7 @@ def _design_capillary( - ``cladding``: cladding index; ``None`` is fused silica. - ``lattice``: tube geometry for a VincettiMode; None builds a Marcatili capillary. """ - bore = _positive("radius", radius) + bore = positive(radius, "radius", ToolsError) wall = _default_cladding(cladding) # The fill is an optmat mixture, which carries its own pressure and temperature. mixture = _resolve_fill(gas, 1.0, temperature, materials) @@ -2754,7 +2741,7 @@ def pressure_rdw( shape=shape, nonlinear_phase=nonlinear_phase, ) - top = _positive("pressure_max", pressure_max) + top = positive(pressure_max, "pressure_max", ToolsError) if top <= _PRESSURE_FLOOR: raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") limits = (_PRESSURE_FLOOR, top) if pressure_limits is None else pressure_limits @@ -2912,7 +2899,7 @@ def __post_init__(self) -> None: ToolsError If the wavelength is not finite and positive. """ - _positive("wavelength", self.wavelength) + positive(self.wavelength, "wavelength", ToolsError) @dataclass(frozen=True) @@ -2979,8 +2966,8 @@ def __post_init__(self) -> None: self.shape, self.nonlinear_phase, ) - _positive("pump", self.pump) - _positive("rdw", self.rdw) + positive(self.pump, "pump", ToolsError) + positive(self.rdw, "rdw", ToolsError) if self.rdw == self.pump: # The linear mismatch and its slope vanish at the pump for every mode. raise ToolsError( @@ -3037,7 +3024,7 @@ def __post_init__(self) -> None: If the order is not positive, the Gaussian is super-Gaussian, or the pulse is chirped. """ - _positive("order", self.order) + positive(self.order, "order", ToolsError) if isinstance(self.pulse, GaussianPulse) and self.pulse.m != 1: raise ToolsError( "the soliton order is defined for sech and Gaussian pulses, and this " @@ -3395,7 +3382,7 @@ def _built( "or (mode, gamma)" ) mode, gamma = result - gamma = _positive("gamma", gamma) + gamma = positive(gamma, "gamma", ToolsError) else: mode, gamma = result, None if not isinstance(mode, Mode): @@ -3603,7 +3590,7 @@ def radius_zdw( lattice=None, materials=materials, ), - _positive("pressure", pressure), + positive(pressure, "pressure", ToolsError), ) roots = solve_parameter( lambda radius: capillary.at_parameters({"core_radius": radius}), diff --git a/tests/driver/test_parameter_paths.py b/tests/driver/test_parameter_paths.py index 7d1ab0c8..11139826 100644 --- a/tests/driver/test_parameter_paths.py +++ b/tests/driver/test_parameter_paths.py @@ -430,7 +430,7 @@ def _parameters(records: pytest.WarningsRecorder) -> list[ParameterPath | None]: lambda: _hollow(fwhm=-1.0), PulseError, "fwhm", - "finite positive number", + "finite and positive", id="capillary-pulse-duration", ), pytest.param( diff --git a/tests/driver/test_prop_planar.py b/tests/driver/test_prop_planar.py index 5a99e0b3..ddb21d29 100644 --- a/tests/driver/test_prop_planar.py +++ b/tests/driver/test_prop_planar.py @@ -385,7 +385,7 @@ def test_an_input_that_is_not_a_planar_launch_is_refused_by_name(): ({"propz": 1e-3}, "propz must be 0"), ({"jones": (1.0, 1.0j)}, "jones takes one entry"), ({"offset_x": math.inf}, "offset_x must be finite"), - ({"waist_x": 0.0}, "waist_x must be a finite positive number"), + ({"waist_x": 0.0}, "waist_x must be finite and positive"), ], ) def test_a_planar_pulse_refuses_what_it_does_not_carry(keywords, match): diff --git a/tests/plotting/test_propagation.py b/tests/plotting/test_propagation.py index 9f39f44e..133a2848 100644 --- a/tests/plotting/test_propagation.py +++ b/tests/plotting/test_propagation.py @@ -6,10 +6,10 @@ import numpy as np import pytest +from solprop._validation import choice from solprop.exceptions import ModeError, ProcessingError from solprop.plotting import propagation, style from solprop.processing._examples import example_output -from solprop.processing.axes import _check_choice def test_prop_2d_draws_one_figure_of_two_panels_for_one_mode(single): @@ -160,8 +160,11 @@ def test_time_1d_draws_into_a_given_axis(single): def test_time_1d_refuses_an_unknown_quantity(): - with pytest.raises(ProcessingError, match="quantity must be one of"): - _check_choice("intensity", propagation.SLICE_QUANTITIES, "quantity") + with pytest.raises( + ProcessingError, + match="quantity must be 'power', 'field' or 'field_squared', got 'intensity'", + ): + choice("intensity", "quantity", propagation.SLICE_QUANTITIES, ProcessingError) def test_time_1d_sums_the_modes_when_asked(multimode): diff --git a/tests/plotting/test_style.py b/tests/plotting/test_style.py index ae0b44d3..211d93cd 100644 --- a/tests/plotting/test_style.py +++ b/tests/plotting/test_style.py @@ -8,13 +8,13 @@ import numpy as np import pytest +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.output import MemoryOutput from solprop.output.schema import GridRecord, ModeRecord, RunMetadata, StepperRecord from solprop.plotting import style from solprop.processing._examples import example_grid, example_output -from solprop.processing.axes import _check_choice def test_cmap_white_starts_at_white(): @@ -137,8 +137,10 @@ def test_corner_text_places_a_label_in_each_corner(corner): def test_corner_text_refuses_an_unknown_corner(): - with pytest.raises(ProcessingError, match="corner must be one of"): - _check_choice("middle", style.CORNERS, "corner") + with pytest.raises( + ProcessingError, match="corner must be 'ul', 'ur', 'll' or 'lr', got 'middle'" + ): + choice("middle", "corner", style.CORNERS, ProcessingError) def test_fwhm_arrows_marks_the_width(): diff --git a/tests/processing/test_axes.py b/tests/processing/test_axes.py index 98c58ae4..548daf5d 100644 --- a/tests/processing/test_axes.py +++ b/tests/processing/test_axes.py @@ -9,6 +9,7 @@ from jaxtyping import TypeCheckError from runtime_types import requires_typecheck +from solprop._validation import choice from solprop.exceptions import GridError, ModeError, OutputError, ProcessingError from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid from solprop.output import MemoryOutput @@ -76,10 +77,14 @@ def test_band_mask_selects_the_active_band(grid): def test_check_choice_refuses_an_unknown_keyword(): - with pytest.raises(ProcessingError, match="band must be one of"): - axes._check_choice("middle", axes.BANDS, "band") - with pytest.raises(ProcessingError, match="axis must be one of"): - axes._check_choice("energy", axes.SPECTRAL_AXES, "axis") + with pytest.raises( + ProcessingError, match="band must be 'active' or 'all', got 'middle'" + ): + choice("middle", "band", axes.BANDS, ProcessingError) + with pytest.raises( + ProcessingError, match="axis must be 'omega', 'f' or 'wavelength', got 'energy'" + ): + choice("energy", "axis", axes.SPECTRAL_AXES, ProcessingError) def test_spectral_axis_carries_the_jacobian_of_each_abscissa(): diff --git a/tests/processing/test_fields.py b/tests/processing/test_fields.py index 56cab2bf..b9f41da5 100644 --- a/tests/processing/test_fields.py +++ b/tests/processing/test_fields.py @@ -6,6 +6,7 @@ import pytest from solprop import fourier, stats +from solprop._validation import choice from solprop.exceptions import ModeError, OutputError, ProcessingError from solprop.output import MemoryOutput from solprop.processing import axes, fields @@ -242,8 +243,11 @@ def test_the_spectral_phase_of_a_dispersed_pulse_is_the_expected_parabola( def test_get_field_refuses_an_unknown_representation(): - with pytest.raises(ProcessingError, match="kind must be one of"): - axes._check_choice("power", fields.FIELD_KINDS, "kind") + with pytest.raises( + ProcessingError, + match="kind must be 'analytic', 'real' or 'envelope', got 'power'", + ): + choice("power", "kind", fields.FIELD_KINDS, ProcessingError) def test_get_field_refuses_an_oversampling_below_one(output): diff --git a/tests/processing/test_noise_floor.py b/tests/processing/test_noise_floor.py index ac143a1f..a8033194 100644 --- a/tests/processing/test_noise_floor.py +++ b/tests/processing/test_noise_floor.py @@ -18,12 +18,13 @@ import numpy as np import pytest +from solprop._validation import choice from solprop.driver import Simulation from solprop.exceptions import ProcessingError from solprop.output.schema import Output from solprop.plotting.propagation import prop_2d, spec_1d from solprop.processing import get_spectral_density, get_spectrum -from solprop.processing.axes import _check_choice, grid_of +from solprop.processing.axes import grid_of from solprop.processing.fields import NOISE_FLOORS, _fft_norm from solprop.stepper import FixedSchedule, RungeKutta @@ -160,8 +161,10 @@ def test_the_floor_is_refused_without_a_seed_in_the_saved_field(runs): get_spectral_density(runs["modified"], noise_floor="subtract") # An unknown name is refused by the same door in production, where no runtime # type checker stands in front of the keyword. - with pytest.raises(ProcessingError, match="noise_floor must be one of"): - _check_choice("remove", NOISE_FLOORS, "noise_floor") + with pytest.raises( + ProcessingError, match="noise_floor must be 'keep' or 'subtract', got 'remove'" + ): + choice("remove", "noise_floor", NOISE_FLOORS, ProcessingError) def test_a_spectrum_takes_no_noise_floor(runs): diff --git a/tests/processing/test_time.py b/tests/processing/test_time.py index e390d151..68bf5566 100644 --- a/tests/processing/test_time.py +++ b/tests/processing/test_time.py @@ -6,8 +6,9 @@ import pytest from solprop import stats +from solprop._validation import choice from solprop.exceptions import ModeError, ProcessingError -from solprop.processing import axes, fields, spectra, time +from solprop.processing import fields, spectra, time def test_moment_is_the_weighted_mean(): @@ -160,8 +161,10 @@ def test_the_arrival_time_of_a_delayed_pulse(synthetic, kind, method): def test_arrival_time_refuses_an_unknown_method(): - with pytest.raises(ProcessingError, match="method must be one of"): - axes._check_choice("centroid", time.ARRIVAL_METHODS, "method") + with pytest.raises( + ProcessingError, match="method must be 'moment' or 'peak', got 'centroid'" + ): + choice("centroid", "method", time.ARRIVAL_METHODS, ProcessingError) def test_the_field_autocorrelation_is_the_energy_at_zero_delay(output): diff --git a/tests/test_gasflow.py b/tests/test_gasflow.py index 233ee1d2..23a692af 100644 --- a/tests/test_gasflow.py +++ b/tests/test_gasflow.py @@ -14,6 +14,7 @@ from scipy.integrate import quad, solve_ivp from scipy.optimize import brentq +from solprop._validation import choice from solprop.constants import BAR, K_B, N_A from solprop.exceptions import GasFlowWarning, MaterialError, TranslationError from solprop.gasflow import ( @@ -22,7 +23,6 @@ REYNOLDS_LAMINAR_LIMIT, FlowReport, _check_regime, - _checked_choice, chamber_pressure, choked_pressure, flow_report, @@ -122,8 +122,10 @@ def test_the_exit_end_of_a_taper_sets_the_sonic_throughput(): assert at_end / at_start == pytest.approx((125.0 / 75.0) ** 2, rel=0.05) # The literal is enforced by the annotation under the test suite's type hook; the # check a caller without a type checker meets is the module's own. - with pytest.raises(TranslationError, match="exit_end must be one of"): - _checked_choice("middle", "exit_end", ("start", "end")) + with pytest.raises( + TranslationError, match="exit_end must be 'start' or 'end', got 'middle'" + ): + choice("middle", "exit_end", ("start", "end"), TranslationError) # --------------------------------------------------------- the independent witness @@ -452,8 +454,10 @@ def test_independent_populations_are_not_one_flow(): def test_the_builder_refuses_what_it_cannot_honour(): - with pytest.raises(TranslationError, match="vacuum_end must be one of"): - _checked_choice("sealed", "vacuum_end", ("choked", "floor")) + with pytest.raises( + TranslationError, match="vacuum_end must be 'choked' or 'floor', got 'sealed'" + ): + choice("sealed", "vacuum_end", ("choked", "floor"), TranslationError) custom = Species("Mystery", gamma(Gas.He), IdealGas()) with pytest.raises(MaterialError, match="vacuum_end='floor'"): steady_fill(custom, [0.0, 1.0], [1.0, 0.0], radius=1e-4) diff --git a/tests/test_pulse_loaders.py b/tests/test_pulse_loaders.py index 3a165729..2c59d9b2 100644 --- a/tests/test_pulse_loaders.py +++ b/tests/test_pulse_loaders.py @@ -561,7 +561,7 @@ def test_a_slightly_negative_power_is_read_as_zero(tmp_path): (GOOD_ROWS, {"floor": 1.0}, r"floor must be a share .* \[0, 1\)"), (GOOD_ROWS, {"floor": -1e-3}, "floor must be a share"), (GOOD_ROWS, {"floor": math.nan}, "floor must be a share"), - (GOOD_ROWS, {"energy": 0.0}, "energy must be a finite positive number"), + (GOOD_ROWS, {"energy": 0.0}, "energy must be finite and positive"), ], ) def test_a_bad_file_or_keyword_is_refused_by_name(text, keywords, message): diff --git a/tests/test_pulses.py b/tests/test_pulses.py index e01805e2..20a8bf00 100644 --- a/tests/test_pulses.py +++ b/tests/test_pulses.py @@ -1032,12 +1032,12 @@ def test_a_broadband_cw_field_is_wider_than_a_monochromatic_one(envelope_grid): [ ({"energy": None}, "one of energy or peak_power must be given"), ({"peak_power": 1e9}, "only one of energy or peak_power"), - ({"energy": -1e-6}, "energy must be a finite positive number"), - ({"energy": 0.0}, "energy must be a finite positive number"), - ({"energy": math.nan}, "energy must be a finite positive number"), - ({"wavelength": 0.0}, "wavelength must be a finite positive number"), - ({"wavelength": -800e-9}, "wavelength must be a finite positive number"), - ({"fwhm": -30e-15}, "fwhm must be a finite positive number"), + ({"energy": -1e-6}, "energy must be finite and positive"), + ({"energy": 0.0}, "energy must be finite and positive"), + ({"energy": math.nan}, "energy must be finite and positive"), + ({"wavelength": 0.0}, "wavelength must be finite and positive"), + ({"wavelength": -800e-9}, "wavelength must be finite and positive"), + ({"fwhm": -30e-15}, "fwhm must be finite and positive"), ({"m": 0}, "m must be a super-Gaussian order"), ({"phase": (1.0, math.inf)}, "phase coefficient must be finite"), ], @@ -1053,8 +1053,8 @@ def test_gaussian_pulse_validation_rejects_bad_settings(overrides, message): [ ({}, "one of fwhm or width must be given"), ({"fwhm": FWHM, "width": FWHM}, "only one of fwhm or width"), - ({"width": -1e-15}, "width must be a finite positive number"), - ({"fwhm": math.inf}, "fwhm must be a finite positive number"), + ({"width": -1e-15}, "width must be finite and positive"), + ({"fwhm": math.inf}, "fwhm must be finite and positive"), ], ) def test_sech_pulse_validation_rejects_bad_durations(overrides, message): @@ -1129,7 +1129,7 @@ def test_an_envelope_too_small_to_scale_is_rejected(field_grid): "spectrum": np.ones(8, complex), "energy": -1.0, }, - "energy must be a finite positive number", + "energy must be finite and positive", ), ( { @@ -1137,7 +1137,7 @@ def test_an_envelope_too_small_to_scale_is_rejected(field_grid): "spectrum": np.ones(8, complex), "wavelength": -1.0, }, - "wavelength must be a finite positive number", + "wavelength must be finite and positive", ), ], ) @@ -1217,8 +1217,8 @@ def test_a_time_data_pulse_rejects_bad_parameters(kwargs, message): @pytest.mark.parametrize( ("overrides", "message"), [ - ({"wavelength": 0.0}, "wavelength must be a finite positive number"), - ({"average_power": -1.0}, "average_power must be a finite positive number"), + ({"wavelength": 0.0}, "wavelength must be finite and positive"), + ({"average_power": -1.0}, "average_power must be finite and positive"), ({"bandwidth": -1e-9}, "bandwidth must be a finite non-negative"), ({"bandwidth": math.inf}, "bandwidth must be a finite non-negative"), ], diff --git a/tests/test_soliton_content.py b/tests/test_soliton_content.py index 3c895c7c..a8b8e3d2 100644 --- a/tests/test_soliton_content.py +++ b/tests/test_soliton_content.py @@ -7,6 +7,7 @@ import math from dataclasses import FrozenInstanceError from types import SimpleNamespace +from typing import get_args import numpy as np import pytest @@ -16,6 +17,7 @@ from runtime_types import TYPECHECKED from solprop import tools +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ModeError, ToolsError from solprop.modes import MarcatiliMode, TubeLattice, VincettiMode @@ -403,4 +405,4 @@ def test_pressure_search_rejects_invalid_pump_dispersion( def test_the_phase_keyword_is_checked_without_the_type_hook(): # The type hook refuses a wrong literal at the public door; production has none. with pytest.raises(ToolsError, match="nonlinear_phase must"): - tools._check_phase("wrong") + choice("wrong", "nonlinear_phase", get_args(tools.NonlinearPhase), ToolsError) diff --git a/tests/test_tools.py b/tests/test_tools.py index 7aa86474..3c295acd 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -9,6 +9,7 @@ import warnings from dataclasses import replace from pathlib import Path +from typing import get_args import numpy as np import pytest @@ -19,6 +20,7 @@ from scipy.special import jn_zeros, jv from solprop import tools +from solprop._validation import choice from solprop.backends.numpy_ref import NumpyRefBackend from solprop.boundaries import absorber from solprop.constants import EPS0, ROOM_TEMPERATURE, C @@ -240,7 +242,7 @@ def test_the_critical_power_is_the_package_townes_power_times_the_index(): @pytest.mark.parametrize( "call", [ - lambda: tools._check_shape("lorentz"), + lambda: choice("lorentz", "shape", get_args(tools.PulseShape), ToolsError), lambda: tools.pulse_width(0.0), lambda: tools.pulse_peak_power(-1.0, 10e-15), lambda: tools.dispersion_length(10e-15, 0.0), diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 00000000..84b4d8ef --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The shared refusals of plain argument conditions, in their one wording.""" + +import math + +import numpy as np +import pytest + +from runtime_types import unchecked +from solprop._validation import choice, finite, positive, shape +from solprop.exceptions import ModeError, PulseError, ToolsError + + +@pytest.mark.parametrize("value", [2, 2.5, np.float64(3.0), True]) +def test_positive_returns_the_number_as_a_float(value): + result = positive(value, "width", ToolsError) + assert type(result) is float + assert result == float(value) + + +@pytest.mark.parametrize("value", [0.0, -1.0, math.nan, math.inf, "1.0", b"1"]) +def test_positive_refuses_by_name_in_the_callers_class(value): + # A string reaches the check only past the annotation, as in production. + with pytest.raises(ToolsError, match=r"width must be finite and positive, got "): + unchecked(positive)(value, "width", ToolsError) + + +def test_positive_carries_the_parameter_and_can_refuse_a_boolean(): + with pytest.raises(PulseError, match="energy must be finite and positive") as hit: + positive(-1.0, "energy", PulseError, parameter="energy") + assert hit.value.parameter == "energy" + with pytest.raises(ModeError, match="cut must be finite and positive, got True"): + positive(True, "cut", ModeError, reject_bool=True) + + +def test_finite_returns_a_float_and_refuses_what_is_not_finite(): + assert finite(-2, "z", ToolsError) == -2.0 + for value in (math.nan, -math.inf, "0"): + with pytest.raises(ToolsError, match="z must be finite, got "): + unchecked(finite)(value, "z", ToolsError) + + +def test_choice_lists_every_allowed_value(): + choice("b", "kind", ("a", "b"), ToolsError) + with pytest.raises(ToolsError, match="kind must be 'a', 'b' or 'c', got 'd'"): + choice("d", "kind", ("a", "b", "c"), ToolsError) + with pytest.raises(ToolsError, match="kind must be 'a', got 'd'"): + choice("d", "kind", ("a",), ToolsError) + + +def test_shape_refuses_another_shape(): + shape((2, 3), (2, 3), "table", ModeError) + expected = r"table must have shape \(2, 3\), got \(3, 2\)" + with pytest.raises(ModeError, match=expected): + shape((3, 2), (2, 3), "table", ModeError) From 5f391ea503956e7b2d0394a1a62e0526610fb2f2 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:47:12 +0000 Subject: [PATCH 03/42] Split output/schema.py into five modules by concern serialisation, provenance, records, metadata and store; a pure move (the definitions' ASTs are unchanged). schema.py stays as a temporary re-export for driver/simulation.py and driver/interface.py until their split lands. Co-Authored-By: Claude Opus 5.5 --- benchmarks/worker.py | 4 +- changelog.d/output-schema-split.breaking.md | 10 + docs/dev/architecture.md | 4 +- docs/dev/conventions.md | 2 +- docs/dev/figures/build.py | 13 +- docs/dev/figures/layers.svg | 191 +- docs/dev/figures/output.svg | 6 +- .../sep-0023-far-field-aperture-spectra.md | 2 +- .../archive/sep-0033-single-precision-mlx.md | 2 +- docs/dev/structure.md | 4 +- docs/guide/performance/progress.md | 2 +- docs/guide/results/output.md | 37 +- docs/guide/results/plotting.md | 3 +- docs/guide/results/processing.md | 2 +- docs/guide/results/provenance.md | 24 +- docs/guide/results/resuming.md | 12 +- docs/guide/results/run-access.md | 4 +- docs/guide/results/statistics.md | 2 +- docs/guide/setup/running.md | 6 +- docs/guide/workflows/compression.md | 2 +- src/solprop/_host.py | 2 +- src/solprop/backends/export.py | 2 +- src/solprop/backends/host/adams.py | 6 +- src/solprop/backends/host/driver.py | 6 +- src/solprop/backends/host/session.py | 4 +- src/solprop/backends/numpy_ref/backend.py | 2 +- src/solprop/backends/protocol.py | 8 +- src/solprop/backends/session.py | 4 +- src/solprop/capabilities.py | 6 +- src/solprop/driver/_scan_batch.py | 4 +- src/solprop/driver/_scan_run.py | 10 +- src/solprop/driver/defaults.py | 6 +- src/solprop/driver/executors/queue.py | 2 +- src/solprop/driver/plane_view.py | 15 +- src/solprop/driver/run.py | 47 +- src/solprop/driver/scan.py | 4 +- src/solprop/driver/service.py | 19 +- src/solprop/driver/summary.py | 11 +- src/solprop/modes/material.py | 2 +- src/solprop/nonlinear/gnlse.py | 4 +- src/solprop/output/__init__.py | 29 +- src/solprop/output/callback.py | 24 +- src/solprop/output/capability.py | 12 +- src/solprop/output/hdf5.py | 69 +- src/solprop/output/memory.py | 39 +- src/solprop/output/metadata.py | 1222 ++++ src/solprop/output/provenance.py | 301 + src/solprop/output/records.py | 1524 +++++ src/solprop/output/resumable.py | 6 +- src/solprop/output/scan.py | 14 +- src/solprop/output/schema.py | 5082 +---------------- src/solprop/output/serialisation.py | 609 ++ src/solprop/output/snapshots.py | 2 +- src/solprop/output/store.py | 1579 +++++ src/solprop/physdata/selection.py | 2 +- src/solprop/plotting/__init__.py | 2 +- src/solprop/plotting/geometry.py | 4 +- src/solprop/plotting/propagation.py | 12 +- src/solprop/plotting/spectrograms.py | 4 +- src/solprop/plotting/statistics.py | 4 +- src/solprop/plotting/style.py | 8 +- src/solprop/processing/__init__.py | 2 +- src/solprop/processing/_examples.py | 4 +- src/solprop/processing/axes.py | 23 +- src/solprop/processing/beams.py | 16 +- src/solprop/processing/coherence.py | 4 +- src/solprop/processing/compression.py | 18 +- src/solprop/processing/fields.py | 19 +- src/solprop/processing/mixtures.py | 4 +- src/solprop/processing/modal.py | 20 +- src/solprop/processing/plane_file.py | 2 +- src/solprop/processing/planes.py | 7 +- src/solprop/processing/scans.py | 12 +- src/solprop/processing/time.py | 20 +- src/solprop/recipe/core.py | 10 +- src/solprop/recipe/preview.py | 2 +- src/solprop/recipe/provenance.py | 4 +- src/solprop/stats.py | 6 +- src/solprop/translate/coupled.py | 4 +- src/solprop/translate/flux.py | 2 +- src/solprop/translate/general.py | 4 +- src/solprop/translate/run_request.py | 8 +- tests/backends/test_dense_output.py | 10 +- tests/backends/test_flux_paths.py | 2 +- tests/backends/test_free_statistics.py | 2 +- tests/backends/test_held_plasma_contract.py | 2 +- tests/backends/test_kperp_collar.py | 2 +- tests/backends/test_low_storage.py | 2 +- tests/backends/test_numpy_ref_backend.py | 2 +- tests/backends/test_radial_statistics.py | 2 +- tests/backends/test_step_limits.py | 3 +- tests/backends/test_transverse_backends.py | 2 +- tests/coupling/test_elements.py | 3 +- tests/coupling/test_plane.py | 2 +- tests/driver/test_absorber_keywords.py | 2 +- tests/driver/test_coupled_input_noise.py | 2 +- tests/driver/test_dispersive_policy.py | 2 +- tests/driver/test_entrance_only_resume.py | 2 +- tests/driver/test_field_free_planes.py | 2 +- tests/driver/test_initial_z_and_checkpoint.py | 4 +- tests/driver/test_interface_defaults.py | 3 +- tests/driver/test_moving_guide.py | 2 +- tests/driver/test_native_resume_history.py | 2 +- tests/driver/test_noise_default.py | 3 +- tests/driver/test_optmat_rates.py | 2 +- tests/driver/test_plane_adoption.py | 3 +- tests/driver/test_plasma_band_propagation.py | 2 +- tests/driver/test_prop_gnlse.py | 2 +- tests/driver/test_prop_radial.py | 2 +- tests/driver/test_prop_slab.py | 2 +- tests/driver/test_resume_gate.py | 2 +- tests/driver/test_resume_history.py | 3 +- tests/driver/test_run.py | 2 +- tests/driver/test_runner.py | 21 +- tests/driver/test_scan_field_retention.py | 2 +- tests/driver/test_scan_queue.py | 3 +- tests/driver/test_scan_worker_validation.py | 2 +- tests/driver/test_simulation.py | 2 +- tests/driver/test_snapshots.py | 2 +- tests/driver/test_stated_input.py | 16 +- tests/driver/test_summary.py | 3 +- tests/driver/test_tdse_entry.py | 2 +- tests/driver/test_tdse_hacc_run.py | 2 +- tests/driver/test_temperature_paths.py | 2 +- tests/driver/test_vincetti.py | 2 +- tests/driver/test_zero_rate_pass.py | 2 +- tests/modes/test_field_scale.py | 2 +- tests/modes/test_stepindex_quadrature.py | 2 +- tests/modes/test_tabulated.py | 2 +- tests/nonlinear/test_gnlse.py | 2 +- tests/nonlinear/test_gnlse_selection.py | 2 +- tests/nonlinear/test_optmat_raman.py | 2 +- tests/nonlinear/test_vector_raman.py | 2 +- tests/output/test_callback.py | 11 +- tests/output/test_checkpoints.py | 7 +- tests/output/test_field_destination.py | 9 +- tests/output/test_field_retention.py | 16 +- tests/output/test_handlers.py | 8 +- tests/output/test_march_snapshot_layout.py | 2 +- tests/output/test_material_resume.py | 3 +- tests/output/test_modal_resume.py | 9 +- tests/output/test_power_convention.py | 3 +- tests/output/test_preallocation.py | 7 +- tests/output/test_recipe_group.py | 2 +- tests/output/test_record_arrays.py | 3 +- tests/output/test_record_reuse.py | 16 +- tests/output/test_scan.py | 2 +- tests/output/test_scan_artifacts.py | 3 +- tests/output/test_schema.py | 55 +- tests/output/test_snapshots.py | 3 +- tests/plotting/test_statistics.py | 9 +- tests/plotting/test_style.py | 3 +- tests/processing/conftest.py | 9 +- tests/processing/test_axes.py | 3 +- tests/processing/test_mixtures.py | 2 +- tests/processing/test_noise_floor.py | 2 +- tests/processing/test_planes.py | 2 +- tests/processing/test_scan_reductions.py | 7 +- tests/recipe/test_recipe_scan.py | 2 +- tests/sessions/runs.py | 2 +- tests/sessions/test_destination_export.py | 3 +- .../test_native_session_carrier_table.py | 2 +- tests/sessions/test_native_session_history.py | 14 +- tests/sessions/test_service_loop.py | 5 +- tests/test_boundaries.py | 2 +- tests/test_collar_length.py | 2 +- tests/test_display.py | 2 +- tests/test_far_field_statistics.py | 3 +- tests/test_monitor.py | 15 +- tests/test_pulses.py | 2 +- tests/test_spatial_boundaries.py | 2 +- tests/test_spatial_statistics.py | 2 +- tests/test_stats.py | 2 +- tests/test_structure_figures.py | 9 +- tests/test_transverse_monitor.py | 2 +- tests/test_transverse_nonlinear.py | 2 +- tests/test_transverse_reads.py | 2 +- tests/test_transverse_witnesses.py | 2 +- tests/test_vector_raman_oracles.py | 2 +- tests/test_weak_signal.py | 2 +- tests/translate/test_operator_settings.py | 2 +- tests/translate/test_run_request.py | 2 +- tests/translate/test_tdse_contract.py | 2 +- tests/vector_raman_declaration.py | 2 +- tools/size_budget.toml | 1 - 185 files changed, 5951 insertions(+), 5723 deletions(-) create mode 100644 changelog.d/output-schema-split.breaking.md create mode 100644 src/solprop/output/metadata.py create mode 100644 src/solprop/output/provenance.py create mode 100644 src/solprop/output/records.py create mode 100644 src/solprop/output/serialisation.py create mode 100644 src/solprop/output/store.py diff --git a/benchmarks/worker.py b/benchmarks/worker.py index 8e5d694b..9a25113a 100644 --- a/benchmarks/worker.py +++ b/benchmarks/worker.py @@ -62,7 +62,7 @@ from solprop.backends.protocol import Backend, HostStepping from solprop.driver import Simulation from solprop.monitor import BoundaryMonitor - from solprop.output.schema import FieldRetention, Output + from solprop.output.store import FieldRetention, Output from solprop.translate.problem import PropagationProblem @@ -622,7 +622,7 @@ def measure( from solprop.backends import select # noqa: PLC0415 from solprop.driver.executors.pool import peak_rss # noqa: PLC0415 from solprop.driver.run import field_retention # noqa: PLC0415 - from solprop.output.schema import serialise # noqa: PLC0415 + from solprop.output.serialisation import serialise # noqa: PLC0415 free_before = _device_free(configuration) started = time.perf_counter() diff --git a/changelog.d/output-schema-split.breaking.md b/changelog.d/output-schema-split.breaking.md new file mode 100644 index 00000000..c08cc692 --- /dev/null +++ b/changelog.d/output-schema-split.breaking.md @@ -0,0 +1,10 @@ +`solprop.output.schema` is split into five modules: `solprop.output.serialisation` +(`serialise`, `ArrayDigest`, `StatedInput`, `recorded_input`), `solprop.output.provenance` +(`BackendRecord`, `GitProvenance`, `package_versions`), `solprop.output.records` +(`GridRecord`, `ModeRecord`, `StepperRecord`, `SavedPlane`, `RunOutcome` and the other +record types), `solprop.output.metadata` (`RunMetadata`, `SCHEMA_VERSION`, +`resolve_metadata_write`) and `solprop.output.store` (`Output`, `PlaneStore`, +`FieldRetention` and the other save protocols). Import these names from +`solprop.output`, which still exports the public ones, or from their new module. A run +whose input array was recorded by its digest names the digest's class by its new module, +so its configuration hash differs from one written before. diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index b265d456..1d0daf86 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -220,7 +220,7 @@ against `src/solprop` by `tests/test_structure_figures.py`. | `stepper/`, `log.py`, `stats.py` | Tableaux, controllers, schedules, progress reporters and statistics specifications | D | | `driver/`, `driver/executors/`, `scan.py` | Simulation and convenience interfaces, scan/batch orchestration, Local/Pool/Queue/Slurm execution and scan CLI | D | | `calibration/` | The native backend's machine calibration ([SEP 46](seps/archive/sep-0046-machine-calibration.md)): reading, showing, exporting, importing and clearing profiles in the per-user store, through solprop-native's standard-library modules loaded by path so that the native runtime never starts (`calibration/_native.py`), the measuring controller, its child processes, the synthetic problems they time, the operation-grain candidates and the selection rule (`calibration/controller.py`, `child.py`, `problems.py`, `grain.py`, `selection.py`), the refinement of one simulation (`calibration/refine.py`, stage 2, which `Simulation.calibrate` runs), and the `solprop calibrate` command (`calibration/command.py`) | D | -| `output/` | Memory and HDF5 outputs, provenance/resume, scan storage and artifacts | D | +| `output/` | Memory and HDF5 outputs, provenance/resume, scan storage and artifacts; the run record in five modules: serialising configuration objects (`serialisation.py`), what executed a run (`provenance.py`), the record types of a saved run and its planes (`records.py`), `RunMetadata` and the resume key (`metadata.py`), and the save protocol, `PlaneStore` and field retention (`store.py`) | D | | `processing/`, `plotting/`, `sol.py` | Field, spectrum, time, coherence, compression, mixture and scan analysis; a saved plane read back as a field plane or a fluence map (`processing/planes.py`), and a field plane's file form (`processing/plane_file.py`); propagation/statistics plots and spectrograms; the interactive session namespace, the one module whose import has side effects | X | | `service/` | Front-end support, above every other layer: the messages between a front end and solprop, the kernel process that answers a front end's checks, previews and readings of a results file as plain data, each run as a job that keeps its record in its folder, and the helper and connection that reach a machine's runs over SSH ([the front end's design](gui/design.md), sections 13.3 and 13.4). Not part of the user API and outside the stability promise; nothing else in solprop imports it | X | @@ -606,7 +606,7 @@ qualified, and §11 its current limits. `MemoryOutput` (everything in RAM: fast, small problems, batch scans) and `HDF5Output` (streaming to disk, chunked per saved plane, optional compression, cache/resume) implement one -save protocol and one metadata schema (`output/schema.py`): grid, modes, simulation type, +save protocol (`output/store.py`) and one metadata schema (`output/metadata.py`, its record types in `output/records.py`): grid, modes, simulation type, the full configuration that built the `Simulation` (serialised physics objects), package and dependency versions, git provenance of the running code when available, the noise seed and realisation, the stepper settings and the step schedule actually taken (z, h, error norm, diff --git a/docs/dev/conventions.md b/docs/dev/conventions.md index 1ab98b11..0808c6be 100644 --- a/docs/dev/conventions.md +++ b/docs/dev/conventions.md @@ -416,4 +416,4 @@ prefix instead: `plot_energy` and `plot_stats`. Two classes with similar columns but different meanings do not share a name: `backends.protocol.BackendHistory` holds each attempt's end and a running clock, and -`output.schema.StepHistory` each attempt's start and its own time. +`output.records.StepHistory` each attempt's start and its own time. diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 216dead8..ccd55921 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -766,6 +766,11 @@ class Layer(NamedTuple): "output/", ( "schema", + "serialisation", + "provenance", + "records", + "metadata", + "store", "capability", "memory", "callback", @@ -1257,7 +1262,7 @@ class Pass(NamedTuple): class Dataset(NamedTuple): - """One dataset or group of a saved run (`output/hdf5.py`, `output/schema.py`).""" + """One dataset or group of a saved run (`output/hdf5.py`, `output/records.py`).""" name: str """Its key in the file.""" @@ -1294,12 +1299,12 @@ class Dataset(NamedTuple): Dataset( "step_history/", "one dataset per column, plus counts", - ("z, h, norm, accepted, wall_time (schema.HISTORY_FIELDS)",), + ("z, h, norm, accepted, wall_time (records.HISTORY_FIELDS)",), ), Dataset( "step_stats/", "the periodic subset between planes, plus counts", - ("z, step (schema.STEP_STATS_INDEX), then one dataset per statistic",), + ("z, step (records.STEP_STATS_INDEX), then one dataset per statistic",), ), Dataset( "meta/json", @@ -2703,7 +2708,7 @@ def output_side() -> drawsvg.Drawing: right_x = MARGIN + FILE_WIDTH + FILE_GAP right_width = WIDTH - MARGIN - right_x subtitle = ( - "architecture.md §10 and output/schema.py. One save protocol and one metadata " + "architecture.md §10 and output/store.py. One save protocol and one metadata " "schema behind MemoryOutput and HDF5Output; the datasets below are the ones " "HDF5Output creates, in the order it creates them." ) diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index c722e22a..c94bdd62 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -1,6 +1,6 @@ + width="1200" height="1870.5" viewBox="0 0 1200 1870.5"> - + The layers of solprop, and every module in each Bands and colours from architecture.md §3 and decisions.md ADR-001; modules from src/solprop. Arrows point from the layer that imports to the layer it imports. @@ -195,88 +195,89 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: backend · batch · passes admission · envelope_plasma general_modal · tdse - - -D -driver -may import -P, T -B through the seam, h5py - + + +D +driver +may import +P, T +B through the seam, h5py + log.py - + stats.py - + stepper/ tableau · runge_kutta · multistep low_storage · controller · schedule - + output/ -schema · capability · memory -callback · hdf5 · tdse_archive -snapshots · scan · scan_artifacts -resumable - -scan.py - -calibration/ -command · controller · first_use -child · grain · problems · refine -selection - -driver/executors/ -local · pool · queue · slurm -batch - -driver/ -simulation · run · summary -interface · scaffold · mixtures -material_output · tdse -tdse_consistency · scan · batch -defaults · estimate · plane_view -service - -recipe/ -core · document · errors · export -metadata · paths · pins · preview -provenance · quantity · registry - -cli.py - - -X -processing / plotting -may import -numpy, scipy -matplotlib, D (outputs) -T (table identities) - -processing/ -beams · axes · fields · spectra -time · coherence · compression -mixtures · modal · planes -plane_file · scans - -plotting/ -propagation · statistics -spectrograms · style · geometry - -sol.py - -capabilities.py - -service/ -agent · beam · beams · client -design · explore · figures · frames -handlers · install · job · kernel -launch · machines · messages -postprocess · recipes · record -reduce · results · run · runs -scan_reductions · sheet · states -waveguide · worker - -processing/ -windows +schema · serialisation · provenance +records · metadata · store +capability · memory · callback +hdf5 · tdse_archive · snapshots +scan · scan_artifacts · resumable + +scan.py + +calibration/ +command · controller · first_use +child · grain · problems · refine +selection + +driver/executors/ +local · pool · queue · slurm +batch + +driver/ +simulation · run · summary +interface · scaffold · mixtures +material_output · tdse +tdse_consistency · scan · batch +defaults · estimate · plane_view +service + +recipe/ +core · document · errors · export +metadata · paths · pins · preview +provenance · quantity · registry + +cli.py + + +X +processing / plotting +may import +numpy, scipy +matplotlib, D (outputs) +T (table identities) + +processing/ +beams · axes · fields · spectra +time · coherence · compression +mixtures · modal · planes +plane_file · scans + +plotting/ +propagation · statistics +spectrograms · style · geometry + +sol.py + +capabilities.py + +service/ +agent · beam · beams · client +design · explore · figures · frames +handlers · install · job · kernel +launch · machines · messages +postprocess · recipes · record +reduce · results · run · runs +scan_reductions · sheet · states +waveguide · worker + +processing/ +windows T → P physics objects @@ -286,28 +287,28 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: D → B backends.select, then the seam - - -X → D a saved run, through the Output protocol - + + +X → D a saved run, through the Output protocol + - -1 - + +1 + - -2 + +2 3 - + - -4 -1 Simulation calls translate.assemble -2 the driver builds the grid, the modes, the pulse and the responses -3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object -4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X -The package-wide modules carry no layer letter: __init__.py · exceptions.py + +4 +1 Simulation calls translate.assemble +2 the driver builds the grid, the modes, the pulse and the responses +3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object +4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X +The package-wide modules carry no layer letter: __init__.py · exceptions.py \ No newline at end of file diff --git a/docs/dev/figures/output.svg b/docs/dev/figures/output.svg index 798eea6f..e5bc0479 100644 --- a/docs/dev/figures/output.svg +++ b/docs/dev/figures/output.svg @@ -71,7 +71,7 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: A saved run, and what reads it back -architecture.md §10 and output/schema.py. One save protocol and one metadata schema behind MemoryOutput and HDF5Output; the datasets below are the ones HDF5Output creates, +architecture.md §10 and output/store.py. One save protocol and one metadata schema behind MemoryOutput and HDF5Output; the datasets below are the ones HDF5Output creates, in the order it creates them. one file what it is for @@ -98,11 +98,11 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: step_history/ one dataset per column, plus counts -z, h, norm, accepted, wall_time (schema.HISTORY_FIELDS) +z, h, norm, accepted, wall_time (records.HISTORY_FIELDS) step_stats/ the periodic subset between planes, plus counts -z, step (schema.STEP_STATS_INDEX), then one dataset per statistic +z, step (records.STEP_STATS_INDEX), then one dataset per statistic meta/json RunMetadata as one JSON dataset diff --git a/docs/dev/seps/archive/sep-0023-far-field-aperture-spectra.md b/docs/dev/seps/archive/sep-0023-far-field-aperture-spectra.md index c4b146f4..d23c9bad 100644 --- a/docs/dev/seps/archive/sep-0023-far-field-aperture-spectra.md +++ b/docs/dev/seps/archive/sep-0023-far-field-aperture-spectra.md @@ -709,7 +709,7 @@ Additive and opt-in: no default changes, `default_spec("free")` is unchanged, an existing statistic changes beyond rounding. The kernel schema and the output schema are unchanged; the new statistics are float64 arrays stored like the others. The two `StatsSpec` fields follow SEP 22's hash-stability rule: each declares -`metadata={"omit_default": True}`, so `output.schema._serialise_dataclass` leaves it out +`metadata={"omit_default": True}`, so `output.serialisation._serialise_dataclass` leaves it out of the configuration record while it holds its default (`None`, and an empty table compared as empty), as the function already does for `PlasmaResponse.integration`; every existing resume key and scan digest, and SEP 22's stored digests, stand; the marker and diff --git a/docs/dev/seps/archive/sep-0033-single-precision-mlx.md b/docs/dev/seps/archive/sep-0033-single-precision-mlx.md index ae5c8566..eb204140 100644 --- a/docs/dev/seps/archive/sep-0033-single-precision-mlx.md +++ b/docs/dev/seps/archive/sep-0033-single-precision-mlx.md @@ -1638,7 +1638,7 @@ points are pending; the earlier decisions on Q1–Q11 have not been reinterprete compatibility with the private scale.** A restart's peak can differ from the launch's, so choosing $s$ in each `set_initial` call can change it. A `MarchSnapshot` contains state, source/history rows and compensated rows, not - only an observed field. The current `output.schema.RunMetadata` also preserves + only an observed field. The current `output.metadata.RunMetadata` also preserves historical configuration hashes through explicit schema guards, while backend provenance is outside the hash. **Recommended change/question:** state whether the scale is retained or recomputed and how every restored row is converted; diff --git a/docs/dev/structure.md b/docs/dev/structure.md index 89672b4c..92540e2b 100644 --- a/docs/dev/structure.md +++ b/docs/dev/structure.md @@ -114,8 +114,8 @@ and what it deliberately leaves out is as much of the design as what it covers. ![A saved run's datasets and metadata, the resume digest, and the accessors that read them back](figures/output.svg) -Text: [architecture.md §10](architecture.md), and `output/schema.py` for the record -types. +Text: [architecture.md §10](architecture.md), and `output/records.py` and +`output/metadata.py` for the record types. The output layer also owns `output/snapshots.py`: coordinated suspension of both writers, immutable publication and verified restoration. This sits above the backend diff --git a/docs/guide/performance/progress.md b/docs/guide/performance/progress.md index 8314d32d..dc619b20 100644 --- a/docs/guide/performance/progress.md +++ b/docs/guide/performance/progress.md @@ -151,7 +151,7 @@ actually be stepped, and it follows `progress=` exactly as the progress lines do | `"none"` | nothing | A run into a plane store (memory or HDF5) also keeps it, as the annotation -{py:data}`~solprop.output.schema.RUN_SUMMARY_ANNOTATION` +{py:data}`~solprop.output.metadata.RUN_SUMMARY_ANNOTATION` (`handler.annotations["run_summary"]`): a list of `{"title", "rows"}`, to which a resume adds its own. It is a description for a person; a program should read the run's [metadata](../results/provenance.md), which holds all of it. The points of a diff --git a/docs/guide/results/output.md b/docs/guide/results/output.md index 13de3a9d..a575189d 100644 --- a/docs/guide/results/output.md +++ b/docs/guide/results/output.md @@ -52,10 +52,10 @@ plane out without touching the rest. They are created at the first save for ever the run will save, and the file's `n_saved` attribute counts the planes written (below). The run loop tells either store how many planes to expect with -{py:meth}`~solprop.output.schema.PlaneStore.reserve`; a handler driven by hand works +{py:meth}`~solprop.output.store.PlaneStore.reserve`; a handler driven by hand works without it, growing as it goes. -Both are {py:class}`~solprop.output.schema.PlaneStore` subclasses, and everything that +Both are {py:class}`~solprop.output.store.PlaneStore` subclasses, and everything that must not differ between them lives in that base class: the conversion applied on save, the metadata overwrite rule, the accessor keys, the step-history layout, and the check that every plane fits the layout the first one fixed (the field's shape, the statistic @@ -91,7 +91,7 @@ vocabulary {py:func}`~solprop.driver.scan.run_scan` already uses: | `"none"` | the statistics and step history alone | A run is **homogeneous**: it keeps its fields by one rule, declared to the output before -its first plane ({py:meth}`~solprop.output.schema.PlaneStore.retain`), and a plane that +its first plane ({py:meth}`~solprop.output.store.PlaneStore.retain`), and a plane that breaks it is refused by name. A plane without its field is not copied, converted or stored, and an in-memory output allocates no field block for it: a statistics-only run holds its statistics. The march is unchanged — the statistics are computed from @@ -131,8 +131,8 @@ output["field_planes"] # the saved planes that kept a field: no Reading a field that was not kept is refused, naming the run's `fields`: `output["field"]`, `output["noise_phase"]` and -{py:meth}`~solprop.output.schema.PlaneStore.fields` need every plane's field and answer -only for an `"all"` run; {py:meth}`~solprop.output.schema.PlaneStore.plane_field` reads +{py:meth}`~solprop.output.store.PlaneStore.fields` need every plane's field and answer +only for an `"all"` run; {py:meth}`~solprop.output.store.PlaneStore.plane_field` reads one saved plane's field and works for any plane that kept one; `output["field_planes"]` lists them. {py:mod}`solprop.processing` reads one plane through `plane_field`, so a processing function asked for a kept plane works and one asked for another plane is @@ -141,8 +141,8 @@ refused by name. {py:meth}`~solprop.output.memory.MemoryOutput.planes` returns writes its terminal checkpoint **with** the field ([resuming](resuming.md)). A handler of your own takes field-free planes by declaring -{py:class}`~solprop.output.schema.RetainingOutput`, one method beside the -{py:class}`~solprop.output.schema.Output` protocol; a run that keeps fewer than every field +{py:class}`~solprop.output.store.RetainingOutput`, one method beside the +{py:class}`~solprop.output.store.Output` protocol; a run that keeps fewer than every field refuses a handler without it, by name. To reduce each plane and keep nothing, no class is needed: {py:class}`~solprop.output.callback.CallbackOutput` is built from a callable ([a scan that reduces each plane](../setup/running.md#a-scan-that-reduces-each-plane)). @@ -165,7 +165,7 @@ class Energies: ... # the rest of the Output protocol ``` -`retain` is given a {py:class}`~solprop.output.schema.FieldRetention` whose indices are +`retain` is given a {py:class}`~solprop.output.store.FieldRetention` whose indices are already counted from the entrance, and the shape `(n_modes, n_pol, n_omega)` of the state, known even when no plane will carry a field. A handler already holding planes under a different rule raises {py:class}`~solprop.exceptions.OutputError`, as a resumed @@ -178,7 +178,7 @@ observation itself can skip the field. ## What goes in, and in which convention -A {py:class}`~solprop.output.schema.SavedPlane` is what a driver hands over: the position +A {py:class}`~solprop.output.records.SavedPlane` is what a driver hands over: the position `z`, the field there as `(n_modes, n_pol, n_omega)` in the **internal** FFT convention (or `None` on a plane that keeps no field), the statistics evaluated at that plane, the step history since the previous plane, and the accumulated noise phase when a noise @@ -203,26 +203,26 @@ $$ \tilde E_{\text{optics},k} = (-1)^k\,\overline{\tilde E_k} $$ and a stored spectral phase uses the declared optics convention and time origin. An ordinary intensity spectrometer does not itself measure that phase. The `field` dataset and the file itself carry the attribute `spectral_convention = "optics"` to say so. -{py:meth}`~solprop.output.schema.PlaneStore.fields` with `convention="fft"` converts +{py:meth}`~solprop.output.store.PlaneStore.fields` with `convention="fft"` converts back, to the form a backend works in. A run makes that conversion only once per kept plane, and where the field is kept. Rather than handing a store a plane with its field and having the store convert it into a second volume, the run's session converts the field straight into the stored form, into the room -the store gives it ({py:meth}`~solprop.output.schema.PlaneStore.field_destination`): +the store gives it ({py:meth}`~solprop.output.store.PlaneStore.field_destination`): `MemoryOutput`'s own row for the plane, so the field is written once, where it stays, or a new array that `HDF5Output` writes into the plane's chunk without a copy. The store then takes the plane with -{py:meth}`~solprop.output.schema.PlaneStore.save_exported`, its field beside it as a -{py:class}`~solprop.output.schema.StoredField`. What is stored is bit for bit what -{py:meth}`~solprop.output.schema.PlaneStore.save` stores for the same field. A store +{py:meth}`~solprop.output.store.PlaneStore.save_exported`, its field beside it as a +{py:class}`~solprop.output.records.StoredField`. What is stored is bit for bit what +{py:meth}`~solprop.output.store.PlaneStore.save` stores for the same field. A store subclass that overrides `save` is still given every plane, with its field in the internal convention, through its `save` -({py:attr}`~solprop.output.schema.PlaneStore.takes_exports`); so is any output that is not +({py:attr}`~solprop.output.store.PlaneStore.takes_exports`); so is any output that is not a `PlaneStore`. Two things stay internal, and are marked so rather than converted: the **noise -realisation** in {py:class}`~solprop.output.schema.NoiseRecord` and the per-plane +realisation** in {py:class}`~solprop.output.records.NoiseRecord` and the per-plane **noise phase**. Neither is a user-facing spectrum — they are the object the stepper adds at every stage and the phase that advances it (see [noise](../media/noise.md)) — and conjugating them would make them disagree with the state they have to be added to. @@ -236,7 +236,7 @@ and that is exactly what the boundary exists to get right. ## The metadata overwrite rule Metadata is written once. -{py:func}`~solprop.output.schema.resolve_metadata_write` is the single rule both handlers +{py:func}`~solprop.output.metadata.resolve_metadata_write` is the single rule both handlers obey: a second record describing the same run — the same configuration hash — is a no-op; one describing a different run raises and names what changed; `force=True` replaces it and logs a warning. @@ -510,7 +510,8 @@ absent from the stored record. A run with no custom rate keeps its record. import numpy as np from solprop.grid import EnvelopeGrid from solprop.output import HDF5Output, MemoryOutput, resume -from solprop.output.schema import GridRecord, RunMetadata, SavedPlane, StepperRecord +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, SavedPlane, StepperRecord from solprop.stepper import DOPRI5, PIController grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) diff --git a/docs/guide/results/plotting.md b/docs/guide/results/plotting.md index 3bcfb6ae..d939feec 100644 --- a/docs/guide/results/plotting.md +++ b/docs/guide/results/plotting.md @@ -175,7 +175,8 @@ axis and the values, per unit of that axis, with the same gate and a `bandpass=` import numpy as np from solprop.grid import EnvelopeGrid from solprop.output import MemoryOutput - from solprop.output.schema import GridRecord, RunMetadata, SavedPlane, StepperRecord + from solprop.output.metadata import RunMetadata + from solprop.output.records import GridRecord, SavedPlane, StepperRecord from solprop.plotting import spectrogram from solprop.pulses import GaussianPulse diff --git a/docs/guide/results/processing.md b/docs/guide/results/processing.md index 6b39dd88..96e57305 100644 --- a/docs/guide/results/processing.md +++ b/docs/guide/results/processing.md @@ -55,7 +55,7 @@ limits for you. ## Rebuilding the axes -A saved run records a {py:class}`~solprop.output.schema.GridRecord` — scalars only, so +A saved run records a {py:class}`~solprop.output.records.GridRecord` — scalars only, so that the metadata stays small and its digest cheap — and not the grid object itself. {py:func}`~solprop.processing.axes.grid_from_record` puts one back together: diff --git a/docs/guide/results/provenance.md b/docs/guide/results/provenance.md index 7eef8358..bff6648d 100644 --- a/docs/guide/results/provenance.md +++ b/docs/guide/results/provenance.md @@ -4,7 +4,7 @@ A saved result needs the grid, field convention, material models, response choic ## The metadata schema -{py:class}`~solprop.output.schema.RunMetadata` is a frozen record with these parts: +{py:class}`~solprop.output.metadata.RunMetadata` is a frozen record with these parts: ```{eval-rst} .. list-table:: @@ -14,13 +14,13 @@ A saved result needs the grid, field convention, material models, response choic * - field - what it holds * - ``grid`` - - A :py:class:`~solprop.output.schema.GridRecord`: the scalars that rebuild the + - A :py:class:`~solprop.output.records.GridRecord`: the scalars that rebuild the frequency axis — kind, sizes, spacings, the reference frequency, the band limits. Scalars only, so the record stays small and the digest cheap. Elsewhere in the record a large array is a reference to its bytes, stored beside the text (:doc:`the file's layout `). * - ``modes`` - - One :py:class:`~solprop.output.schema.ModeRecord` per propagated mode: its class, + - One :py:class:`~solprop.output.records.ModeRecord` per propagated mode: its class, its effective area, its serialised configuration and its ``label``, the physical channel that row of the mode axis is, as :py:meth:`~solprop.modes.protocol.ChannelLabel.as_mapping` writes it. @@ -60,14 +60,14 @@ A saved result needs the grid, field convention, material models, response choic native source filter with its temporal-collar geometry. No native runtime handle is stored. * - ``stepper`` - - A :py:class:`~solprop.output.schema.StepperRecord`: the tableau by name, the + - A :py:class:`~solprop.output.records.StepperRecord`: the tableau by name, the controller, the step limits, and the fixed schedule actually taken, so a run can be replayed step for step. * - ``noise`` - - A :py:class:`~solprop.output.schema.NoiseRecord`: the model, the seed and the + - A :py:class:`~solprop.output.records.NoiseRecord`: the model, the seed and the realisation itself, so an ensemble member is reproducible. * - ``backend`` - - A :py:class:`~solprop.output.schema.BackendRecord`: what executed the run -- the + - A :py:class:`~solprop.output.provenance.BackendRecord`: what executed the run -- the backend's name, the propagator mode it resolved, and its threading and planning settings (:doc:`running `). * - ``versions``, ``git``, ``created`` @@ -99,7 +99,7 @@ h5py's and Python's. The metadata says how the run started; the **execution record** says how each execution of it ran. An HDF5 file keeps it at `/meta/outcome` -({py:class}`~solprop.output.schema.RunOutcome`, read as `stored.outcome`), and +({py:class}`~solprop.output.records.RunOutcome`, read as `stored.outcome`), and `outcome.execution` holds, beside the backend's own report (its pools and plans): - `host`: `system`, `release`, `machine` (the architecture), `cpu_model`, @@ -203,7 +203,7 @@ closed file is reopened briefly for it), `MemoryOutput.to_file` copies them, and {py:func}`~solprop.output.hdf5.open_file` reads them back. A file without any reads with an empty mapping. -One is defined, {py:data}`~solprop.output.schema.PEAK_FIELDS_ANNOTATION` +One is defined, {py:data}`~solprop.output.metadata.PEAK_FIELDS_ANNOTATION` (`"material_peak_fields"`): the material manifest's post-run diagnostic of a run with optmat ionisation rates ([ionisation](../media/ionisation.md#rates-from-optmat)), one mapping per population — the largest field it reached against its model's maximum field, and @@ -213,7 +213,7 @@ rate writes none. ```{code-block} python from solprop.output import open_file -from solprop.output.schema import PEAK_FIELDS_ANNOTATION +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION with open_file("run.h5") as stored: (report,) = stored.annotations.get(PEAK_FIELDS_ANNOTATION, []) @@ -221,14 +221,14 @@ with open_file("run.h5") as stored: ``` The run's [start-of-run summary](../performance/progress.md#what-a-run-says-before-it-starts) -is kept the same way, as {py:data}`~solprop.output.schema.RUN_SUMMARY_ANNOTATION` +is kept the same way, as {py:data}`~solprop.output.metadata.RUN_SUMMARY_ANNOTATION` (`"run_summary"`): written when the run starts rather than when it closes, a list with one `{"title", "rows"}` mapping per start, so a resumed run adds its continuation's. It is a description for a person, built from the record and the backend's execution facts. ## Serialising the physics, and admitting what will not serialise -{py:func}`~solprop.output.schema.serialise` is total and idempotent. Dataclasses become +{py:func}`~solprop.output.serialisation.serialise` is total and idempotent. Dataclasses become tagged dictionaries of their constructor fields, enumerations carry their qualified class name as well as their value, NumPy arrays become nested lists with their dtype (a complex array splitting into real and imaginary parts, since JSON has no complex number), and @@ -254,5 +254,5 @@ changes the hash. This covers tabulated cladding glasses, among others. A file records the resolved configuration, the package versions, and the commit with a dirty flag — never the source text, which would make every file carry a copy of the package. When there is no checkout, or git is absent, or the -command fails, {py:meth}`~solprop.output.schema.GitProvenance.detect` returns `None` +command fails, {py:meth}`~solprop.output.provenance.GitProvenance.detect` returns `None` rather than a sentence dressed up as a hash. diff --git a/docs/guide/results/resuming.md b/docs/guide/results/resuming.md index 9b8d2288..f76bb1ee 100644 --- a/docs/guide/results/resuming.md +++ b/docs/guide/results/resuming.md @@ -73,8 +73,8 @@ points by the same hash ([scans](../workflows/scans.md)). ### The resume key The hash a resume compares is -{py:meth}`~solprop.output.schema.RunMetadata.configuration_hash`, the digest of the hashed -part of the run record, {py:meth}`~solprop.output.schema.RunMetadata.hashed`. It is stamped +{py:meth}`~solprop.output.metadata.RunMetadata.configuration_hash`, the digest of the hashed +part of the run record, {py:meth}`~solprop.output.metadata.RunMetadata.hashed`. It is stamped in every file; the stamp tells whether the file is intact, and a suspended writer, a snapshot and a scan row compare it too. @@ -212,8 +212,8 @@ handler only then. ## Completion status and terminal checkpoints Both standard handlers implement the optional -{py:class}`~solprop.output.schema.CheckpointOutput` capability. Their `outcome` -property is a {py:class}`~solprop.output.schema.RunOutcome`: `running`, `complete`, +{py:class}`~solprop.output.store.CheckpointOutput` capability. Their `outcome` +property is a {py:class}`~solprop.output.records.RunOutcome`: `running`, `complete`, `budget_exhausted` or `failed`, together with the actual position, a stop message, cumulative logical and atomic call ledgers, and the latest execution facts. The `previous_executions` records retain earlier runs of the same resumed file. These @@ -221,7 +221,7 @@ records are outside the scientific configuration hash. A budget stop is unfinish it never becomes a successful result by changing the requested end position. An ordinary exception or a call-budget stop also writes a -{py:class}`~solprop.output.schema.TerminalCheckpoint` at the last available finite +{py:class}`~solprop.output.records.TerminalCheckpoint` at the last available finite field. Normally this is an accepted step endpoint. A failure while processing a dense observation can instead retain that interpolated field. It is separate from the regular observations: `n_saved`, `handler["z"]`, `handler["field"]` and the per-plane statistics keep their original meanings. @@ -332,7 +332,7 @@ last plane A finished field-free file resumes as finished, running nothing. A run continuing a file keeps its fields by the file's rule, and is refused if it asks for another. -Custom handlers implementing only {py:class}`~solprop.output.schema.Output` remain +Custom handlers implementing only {py:class}`~solprop.output.store.Output` remain usable for ordinary propagation, keeping every field; a field-free run needs a handler that can hold field-free planes (`retain`), as both standard handlers do. TDSE requires the checkpoint capability and refuses an incompatible handler before native preparation. diff --git a/docs/guide/results/run-access.md b/docs/guide/results/run-access.md index 377ec047..56e1f763 100644 --- a/docs/guide/results/run-access.md +++ b/docs/guide/results/run-access.md @@ -126,7 +126,7 @@ protect nothing. The package versions and the git provenance are outside the dig the same reason, so that a resume survives an unrelated commit or a NumPy upgrade. What *did* execute the run is recorded beside the digest rather than inside it, in the -`backend` group of {py:class}`~solprop.output.schema.RunMetadata`: the backend's name, +`backend` group of {py:class}`~solprop.output.metadata.RunMetadata`: the backend's name, the propagator mode it resolved, and whatever else it reports — for `native` the thread counts, the sizes chosen per operation, the build record and the FFTW wisdom digest. The thread configuration alone has been measured to change the time of one step by a factor @@ -151,5 +151,5 @@ continues the uninterrupted run's computation, agreeing with it to rounding whil For a resume to work in a *new process*, everything in the configuration has to hash the same way twice. Objects that carry their data — the pulses, the profiles, the responses, the dispersion-specified mode — do. A configuration holding a bare callable does not: -{py:func}`~solprop.output.schema.serialise` records one as its `repr`, which carries a +{py:func}`~solprop.output.serialisation.serialise` records one as its `repr`, which carries a memory address. Prefer a specification over a closure wherever there is a choice. diff --git a/docs/guide/results/statistics.md b/docs/guide/results/statistics.md index 9ae6bcd4..3632adbd 100644 --- a/docs/guide/results/statistics.md +++ b/docs/guide/results/statistics.md @@ -664,7 +664,7 @@ statistic with no profile, and the message lists the profiles that were given. ```{code-block} python import numpy as np from solprop.grid import EnvelopeGrid -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.pulses import GaussianPulse from solprop.stats import StatsSpec, collect diff --git a/docs/guide/setup/running.md b/docs/guide/setup/running.md index 21d33b15..da3cb595 100644 --- a/docs/guide/setup/running.md +++ b/docs/guide/setup/running.md @@ -89,7 +89,7 @@ output = prop_gnlse( That builds an envelope grid, maps `gamma` onto a third-order susceptibility, wraps the Taylor coefficients in a dispersion-specified mode ({py:class}`~solprop.nonlinear.gnlse.SpecifiedMode`), builds the input pulse, selects the backend, propagates the pulse and stores 201 planes. It -returns the {py:class}`~solprop.output.schema.Output` it wrote — here a +returns the {py:class}`~solprop.output.store.Output` it wrote — here a {py:class}`~solprop.output.memory.MemoryOutput`, because no `output` was given. Units are SI throughout ([conventions.md §1](../../dev/conventions.md)), with one exception @@ -522,7 +522,7 @@ hashing per input rather than gigabytes of JSON. A caller that already knows what its input is need not pay even that. Both `with_input` and `SimulationRunner.run` take `input_identity=`, a string the record then holds with the -array's dtype and shape ({py:class}`~solprop.output.schema.StatedInput`) in place of the +array's dtype and shape ({py:class}`~solprop.output.serialisation.StatedInput`) in place of the array's own record, without reading its bytes, so the run's hash follows the string: ```{code-block} python @@ -560,7 +560,7 @@ no setting keeps one without the other, so settle the window on one input with t A scan whose result is a few numbers per input has no use for a store. {py:class}`~solprop.output.callback.CallbackOutput` is a handler built from a callable: it -gives each {py:class}`~solprop.output.schema.SavedPlane` to the callable, the entrance +gives each {py:class}`~solprop.output.records.SavedPlane` to the callable, the entrance first, and keeps nothing, so the body of a hand loop over the backend moves into a few lines. Most reductions want statistics and no field, which is a run with `fields="none"` and the statistics it names: diff --git a/docs/guide/workflows/compression.md b/docs/guide/workflows/compression.md index 60f0d7dd..2bde3a74 100644 --- a/docs/guide/workflows/compression.md +++ b/docs/guide/workflows/compression.md @@ -280,7 +280,7 @@ Compression by taylor The evaluation count is SciPy's and moves with its version; the tests mask it. -The compressors take an {py:class}`~solprop.output.schema.Output` (the last plane by +The compressors take an {py:class}`~solprop.output.store.Output` (the last plane by default; `plane=`, `modes=`, `bandpass=` select) or, for a pulse that has not been propagated, `grid=` with the internal-convention `spectrum=` that `pulse.build(grid)` returns. `compress_taylor` searches up to `order=2` (the GDD alone) unless told diff --git a/src/solprop/_host.py b/src/solprop/_host.py index 3e325187..a72a62e4 100644 --- a/src/solprop/_host.py +++ b/src/solprop/_host.py @@ -9,7 +9,7 @@ needed to inspect a case afterwards: the architecture, the CPU model, the CPU counts, the memory and the operating system (:func:`machine`), and the wall time, CPU time and peak resident memory since a :class:`RunClock` started (:func:`usage`). The driver -writes both into each execution's record (:class:`solprop.output.schema.RunOutcome`), +writes both into each execution's record (:class:`solprop.output.records.RunOutcome`), so a resume on another machine keeps its own. Neither costs a run anything measurable: :func:`machine` is asked once per process (at diff --git a/src/solprop/backends/export.py b/src/solprop/backends/export.py index edc4c0f5..f00243d8 100644 --- a/src/solprop/backends/export.py +++ b/src/solprop/backends/export.py @@ -21,7 +21,7 @@ or a newly owned array the session allocates. Either way the result is detached: it is never a view of a live workspace, and the session never writes to it again. A plane store asks for its own room -(:meth:`solprop.output.schema.PlaneStore.field_destination`), so that a kept field is +(:meth:`solprop.output.store.PlaneStore.field_destination`), so that a kept field is written once, where it is kept. The destination is always host memory. A session whose state lives on a device fills it diff --git a/src/solprop/backends/host/adams.py b/src/solprop/backends/host/adams.py index de04bff4..9186f80f 100644 --- a/src/solprop/backends/host/adams.py +++ b/src/solprop/backends/host/adams.py @@ -34,7 +34,7 @@ _State, ) from solprop.exceptions import BackendError, ConvergenceError, ResponseBudgetError -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stepper.controller import limit_step from solprop.stepper.multistep import MarchPolicy, Multistep, march_policy from solprop.stepper.schedule import FixedSchedule, landing @@ -171,7 +171,7 @@ def snapshot(self) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The snapshot, or ``None`` where there is nothing a resume could continue: no history yet, or the march ahead of an interpolated observation. """ @@ -223,7 +223,7 @@ def load(self, snapshot: MarchSnapshot) -> str | None: Parameters ---------- - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot What :meth:`snapshot` returned, possibly read back from a file. Returns diff --git a/src/solprop/backends/host/driver.py b/src/solprop/backends/host/driver.py index 6e824623..20501096 100644 --- a/src/solprop/backends/host/driver.py +++ b/src/solprop/backends/host/driver.py @@ -59,7 +59,7 @@ from solprop.modes.quadrature import mode_matrix, on_axis_rule from solprop.monitor import BoundaryEnergies, BoundaryMonitor from solprop.noise import NoiseRealisation -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.profiles import ZProfile from solprop.stats import StatsSpec from solprop.stepper.controller import ControlRecord @@ -841,7 +841,7 @@ def march_snapshot(self) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The snapshot, or ``None`` for the Runge-Kutta and low-storage marches and wherever the Adams march has nothing a resume could continue (:meth:`solprop.backends.host.adams.HostAdamsMarch.snapshot`). @@ -899,7 +899,7 @@ def restore_march(self, snapshot: MarchSnapshot) -> str | None: Parameters ---------- - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot The snapshot a file holds. Returns diff --git a/src/solprop/backends/host/session.py b/src/solprop/backends/host/session.py index 40c26b4b..fafc6583 100644 --- a/src/solprop/backends/host/session.py +++ b/src/solprop/backends/host/session.py @@ -78,7 +78,7 @@ from solprop.exceptions import BackendError, StopRequested from solprop.log import StopFlag from solprop.monitor import BoundaryMonitor -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.translate.problem import PropagationProblem from solprop.translate.run_request import RunRequest @@ -453,7 +453,7 @@ def march_snapshot(self, ticket: ExportTicket) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The history at the ticket's position, or ``None``. """ self._ledger.check_ticket(ticket) diff --git a/src/solprop/backends/numpy_ref/backend.py b/src/solprop/backends/numpy_ref/backend.py index 6b6d1818..ef38b9a6 100644 --- a/src/solprop/backends/numpy_ref/backend.py +++ b/src/solprop/backends/numpy_ref/backend.py @@ -76,7 +76,7 @@ refusal_for, withheld_accessors, ) -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.profiles import ZProfile from solprop.stats import ( _POYNTING_STATISTICS, diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index ff3bbf64..8a577d53 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -39,7 +39,7 @@ from solprop.log import ProgressReporter, StopFlag from solprop.monitor import BoundaryMonitor from solprop.noise import NoiseRealisation -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stats import StatsSpec if TYPE_CHECKING: @@ -720,7 +720,7 @@ def metadata(self) -> dict[str, object]: thread configuration alone moved one step by a factor of nine (``docs/dev/backends.md`` section 3.4), so a result whose execution is not recorded is not reproducible. The driver writes this into - :attr:`solprop.output.schema.RunMetadata.backend`, which is outside the resume + :attr:`solprop.output.metadata.RunMetadata.backend`, which is outside the resume key -- a run continued on the other backend is the same run. Returns @@ -980,7 +980,7 @@ def march_snapshot(self) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The snapshot, or ``None`` wherever the march has nothing to continue. """ ... @@ -1005,7 +1005,7 @@ def restore_march(self, snapshot: MarchSnapshot) -> str | None: Parameters ---------- - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot The snapshot a file holds. Returns diff --git a/src/solprop/backends/session.py b/src/solprop/backends/session.py index 17ed172b..b6b987b4 100644 --- a/src/solprop/backends/session.py +++ b/src/solprop/backends/session.py @@ -79,7 +79,7 @@ from solprop.exceptions import BackendError from solprop.log import StopFlag from solprop.noise import NoiseRealisation -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stats import CATALOGUE, PLASMA_INTEGRATION_STATISTICS from solprop.translate.problem import PropagationProblem from solprop.translate.run_request import RunRequest @@ -491,7 +491,7 @@ class Start: The run's noise realisation, when a noise model is active. plane_index : int The first save plane strictly beyond `z`. - march : solprop.output.schema.MarchSnapshot or None + march : solprop.output.records.MarchSnapshot or None A method history to restore, when resuming one that was retained. ledgers : collections.abc.Mapping or None Persisted call ledgers to restore (TDSE and budgeted sources). diff --git a/src/solprop/capabilities.py b/src/solprop/capabilities.py index 5d7deb26..4f67ed7e 100644 --- a/src/solprop/capabilities.py +++ b/src/solprop/capabilities.py @@ -48,7 +48,7 @@ ) from solprop.output.hdf5 import open_file from solprop.output.resumable import Resumability, resumability -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.physdata.gases import Gas, as_gas, is_molecule from solprop.physdata.selection import MaterialRecord @@ -197,7 +197,7 @@ def output(source: str | Path | Output) -> OutputCapabilities: Parameters ---------- - source : str, pathlib.Path or solprop.output.schema.Output + source : str, pathlib.Path or solprop.output.store.Output The file, or an opened handler (then nothing is said of resuming it). Returns @@ -213,7 +213,7 @@ def output(source: str | Path | Output) -> OutputCapabilities: Examples -------- >>> from solprop.capabilities import output - >>> from solprop.output.schema import _example_metadata + >>> from solprop.output.metadata import _example_metadata >>> from solprop.output import MemoryOutput >>> handler = MemoryOutput() >>> handler.open(_example_metadata()) diff --git a/src/solprop/driver/_scan_batch.py b/src/solprop/driver/_scan_batch.py index 7a816f95..3d023e08 100644 --- a/src/solprop/driver/_scan_batch.py +++ b/src/solprop/driver/_scan_batch.py @@ -40,8 +40,10 @@ from solprop.driver.run import _history_of from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError +from solprop.output.metadata import RunMetadata +from solprop.output.provenance import BackendRecord +from solprop.output.records import SavedPlane, StepStats from solprop.output.scan import PointResult -from solprop.output.schema import BackendRecord, RunMetadata, SavedPlane, StepStats from solprop.processing.scans import _provenance logger = logging.getLogger(__name__) diff --git a/src/solprop/driver/_scan_run.py b/src/solprop/driver/_scan_run.py index 973f3e79..f575d13a 100644 --- a/src/solprop/driver/_scan_run.py +++ b/src/solprop/driver/_scan_run.py @@ -50,14 +50,10 @@ ) from solprop.output.hdf5 import HDF5Output, _dataset, open_file from solprop.output.memory import MemoryOutput +from solprop.output.metadata import RunMetadata +from solprop.output.records import RecipeRecord, SavedPlane from solprop.output.scan import PointResult, ScanWriter -from solprop.output.schema import ( - FieldRetention, - RecipeRecord, - RunMetadata, - SavedPlane, - field_retention_refusal, -) +from solprop.output.store import FieldRetention, field_retention_refusal from solprop.processing.scans import Reduction, ScanOutput, _Builtin, _provenance from solprop.translate.problem import PropagationProblem from solprop.translate.routes import tdse_of diff --git a/src/solprop/driver/defaults.py b/src/solprop/driver/defaults.py index 0fb0d5fa..8155b3c5 100644 --- a/src/solprop/driver/defaults.py +++ b/src/solprop/driver/defaults.py @@ -14,8 +14,8 @@ ``docs/guide/numerics/choosing-a-stepper.md``. A default is resolved once, when the settings are built, and the choice is recorded -(:class:`solprop.output.schema.NoiseResolution` for the noise, -:class:`solprop.output.schema.DefaultsResolution` for the rest), so a run that took a +(:class:`solprop.output.records.NoiseResolution` for the noise, +:class:`solprop.output.records.DefaultsResolution` for the rest), so a run that took a default says so in its summary and its record. Layer D of ``docs/dev/architecture.md`` section 3, beside the constructors that read it. @@ -28,7 +28,7 @@ from typing import Literal, cast from solprop.exceptions import DriverError, SolpropWarning -from solprop.output.schema import NoiseResolution +from solprop.output.records import NoiseResolution from solprop.stepper.controller import PIController from solprop.stepper.multistep import Multistep from solprop.stepper.runge_kutta import RungeKutta diff --git a/src/solprop/driver/executors/queue.py b/src/solprop/driver/executors/queue.py index 296f67aa..92e30118 100644 --- a/src/solprop/driver/executors/queue.py +++ b/src/solprop/driver/executors/queue.py @@ -41,7 +41,7 @@ from solprop.output.hdf5 import _dataset, _group from solprop.output.scan import PointResult, ScanWriter, _hash, _json from solprop.output.scan_artifacts import artifact_digest, read_artifact, write_artifact -from solprop.output.schema import _decode +from solprop.output.serialisation import _decode from solprop.processing.scans import ScanOutput __all__ = ["Queue", "collect"] diff --git a/src/solprop/driver/plane_view.py b/src/solprop/driver/plane_view.py index b7759995..81461b16 100644 --- a/src/solprop/driver/plane_view.py +++ b/src/solprop/driver/plane_view.py @@ -26,7 +26,8 @@ from solprop.log import PlaneObserver, ProgressReporter from solprop.output.capability import BasisCapability, capability_of from solprop.output.memory import MemoryOutput -from solprop.output.schema import RunMetadata, SavedPlane, StoredField +from solprop.output.metadata import RunMetadata +from solprop.output.records import SavedPlane, StoredField from solprop.processing import fields if TYPE_CHECKING: @@ -46,17 +47,17 @@ class PlaneView: Parameters ---------- - plane : solprop.output.schema.SavedPlane + plane : solprop.output.records.SavedPlane The plane as committed, its field ``None`` where the output keeps none. index : int Its index among the saved planes. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The run's record. fetch : callable or None Returns the plane's field where it was not kept; ``None`` where it was. spend : solprop._host.ObserverSpend The run's observer ledger, which counts the fetches. - stored : solprop.output.schema.StoredField or None, optional + stored : solprop.output.records.StoredField or None, optional The kept field as the output stored it, where it was exported straight into the output (SEP 47 section 5.4) and the plane carries none: the view reads that, with no fetch. @@ -264,7 +265,7 @@ class PlaneWatch: ---------- reporter : solprop.log.ProgressReporter The run's reporter; only a :class:`~solprop.log.PlaneObserver` is shown planes. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The run's record. fetch : callable Returns the field at the current plane, for a plane the output keeps none of. @@ -292,11 +293,11 @@ def show( Parameters ---------- - plane : solprop.output.schema.SavedPlane + plane : solprop.output.records.SavedPlane The plane as committed. index : int Its index among the saved planes. - stored : solprop.output.schema.StoredField or None, optional + stored : solprop.output.records.StoredField or None, optional Its kept field as the output stored it, where it was exported straight into the output and the plane carries none. diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index df0341d7..1753a904 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -13,7 +13,7 @@ **What this module does not do.** It never conjugates a spectrum and never touches the state between planes: the backend owns the step loop and the output handler owns the sign convention on disk (``docs/dev/conventions.md`` section 2, boundary place 2). A -plane is carried across as a :class:`solprop.output.schema.SavedPlane` in the internal +plane is carried across as a :class:`solprop.output.records.SavedPlane` in the internal FFT convention, with modal amplitudes converted to true-power units. Progress is reported through :class:`solprop.log.ProgressReporter` -- the run's summary @@ -66,26 +66,27 @@ withheld_accessors, ) from solprop.output.hdf5 import HDF5Output -from solprop.output.schema import ( - RUN_SUMMARY_ANNOTATION, - BackendRecord, - CheckpointOutput, - FieldRetention, - FieldsChoice, +from solprop.output.metadata import RUN_SUMMARY_ANNOTATION, RunMetadata +from solprop.output.provenance import BackendRecord +from solprop.output.records import ( MarchSnapshot, - Output, - PlaneStore, RecipeRecord, - RetainingOutput, - RunMetadata, RunOutcome, SavedPlane, StepHistory, StepStats, TerminalCheckpoint, - field_retention_refusal, ) from solprop.output.snapshots import SnapshotController +from solprop.output.store import ( + CheckpointOutput, + FieldRetention, + FieldsChoice, + Output, + PlaneStore, + RetainingOutput, + field_retention_refusal, +) from solprop.output.tdse_archive import TDSEArchiveWriter from solprop.stats import StatsSpec from solprop.stepper.low_storage import LowStorageRungeKutta @@ -127,7 +128,7 @@ def _history_of(history: BackendHistory) -> StepHistory: on the two sides of the seam: the backend's ``z`` is the position at the *end* of each attempt and its ``wall_time`` is the clock reading since the previous observation, which is what a stepper measures cheaply, while - :class:`solprop.output.schema.StepHistory` stores the position at the *start* of + :class:`solprop.output.records.StepHistory` stores the position at the *start* of each attempt and the time that one attempt took, which is what a reader wants per row. The conversion is made here: the start is the end less the length, to round-off, and the per-attempt times are the differences of the clock, the first @@ -174,7 +175,7 @@ def plane_from_observation( Returns ------- - solprop.output.schema.SavedPlane + solprop.output.records.SavedPlane The position, the field, the statistics and the step history. Examples @@ -249,7 +250,7 @@ def march_snapshot(self) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The session's snapshot (:meth:`~solprop.backends.session.RunSession.march_snapshot`), or ``None`` without one or with no batch pending, for which the session's @@ -338,7 +339,7 @@ def run_problem( retention, statistics, monitor and history (:meth:`~solprop.translate.run_request.RunRequest.for_run`). The run loads `start` into it and leaves it open. - output : solprop.output.schema.Output + output : solprop.output.store.Output Where the planes go. Closed when the loop ends, whether or not it succeeded. start : solprop.backends.session.Start Where the run starts: :meth:`~solprop.backends.session.Start.entrance` for a @@ -347,7 +348,7 @@ def run_problem( terminal checkpoint at its actual absolute position; the launch plane is written when the handler holds no regular observations, so a resumed run never stores it twice. The session takes the start's field. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The record of the run, written when the handler opens. :func:`solprop.driver.simulation.metadata_for` builds one. stats_spec : solprop.stats.StatsSpec, optional @@ -405,7 +406,7 @@ def run_problem( ``interrupted``, and raises :class:`~solprop.exceptions.StopRequested`, which :meth:`~solprop.driver.simulation.Simulation.run` turns into a return. - recipe : solprop.output.schema.RecipeRecord or None, optional + recipe : solprop.output.records.RecipeRecord or None, optional The recipe the run was built from (SEP 41, S2), recorded in a plane store at the first run start and never rewritten; ``None`` records none. restoration : collections.abc.Mapping or None, optional @@ -670,7 +671,7 @@ def field_retention( ) -> FieldRetention: """Return a run's field retention, resolved against its saved planes. - The driver's door to :func:`solprop.output.schema.field_retention_refusal`, the + The driver's door to :func:`solprop.output.store.field_retention_refusal`, the validator :class:`~solprop.driver.simulation.SimulationSettings` and :func:`~solprop.driver.scan.run_scan` share. @@ -685,7 +686,7 @@ def field_retention( Returns ------- - solprop.output.schema.FieldRetention + solprop.output.store.FieldRetention The rule, its indices counted from the entrance. Raises @@ -743,7 +744,7 @@ def _check_retainable(output: Output, retention: FieldRetention) -> None: """Refuse a field-free rule for an output that cannot hold one. Checked before the output is opened. An output can hold one when it has ``retain`` - (:class:`~solprop.output.schema.RetainingOutput`). + (:class:`~solprop.output.store.RetainingOutput`). """ if retention.fields != "all" and not isinstance(output, RetainingOutput): msg = ( @@ -961,7 +962,7 @@ def retention_at( Parameters ---------- execution : collections.abc.Mapping or None - An execution record (:attr:`solprop.output.schema.RunOutcome.execution`). + An execution record (:attr:`solprop.output.records.RunOutcome.execution`). z : float The position a resume starts from, m. @@ -1219,7 +1220,7 @@ class _Status: Attributes ---------- status : {'running', 'complete', 'budget_exhausted', 'failed'} - The run's status (:class:`~solprop.output.schema.RunOutcome`). + The run's status (:class:`~solprop.output.records.RunOutcome`). z : float Last accepted or saved position, m. message : str diff --git a/src/solprop/driver/scan.py b/src/solprop/driver/scan.py index aab63f1d..5c079376 100644 --- a/src/solprop/driver/scan.py +++ b/src/solprop/driver/scan.py @@ -13,7 +13,7 @@ from solprop.driver.executors import Executor from solprop.driver.simulation import Simulation from solprop.log import ProgressChoice, ScanReporter, StopFlag -from solprop.output.schema import RecipeRecord +from solprop.output.records import RecipeRecord from solprop.processing.scans import Reduction, ScanOutput __all__ = ["Scan", "ScanPoint", "run_scan"] @@ -80,7 +80,7 @@ def run_scan( again, and the scan returns normally, its file holding the points finished before the stop; it logs at ``INFO`` that it stopped, and a scan reporter is told ``"stopped"`` for each stopped point. ``Queue`` and ``Slurm`` refuse it. - recipe : solprop.output.schema.RecipeRecord or None, optional + recipe : solprop.output.records.RecipeRecord or None, optional The recipe the scan was built from, which the scan file records once, so that it reopens with its setup (SEP 41, S2); :meth:`solprop.recipe.core.Recipe.scan` passes it among its keywords. ``None`` records none. diff --git a/src/solprop/driver/service.py b/src/solprop/driver/service.py index ec906250..8986efe6 100644 --- a/src/solprop/driver/service.py +++ b/src/solprop/driver/service.py @@ -76,13 +76,9 @@ SummarisingReporter, ) from solprop.output.hdf5 import HDF5Output -from solprop.output.schema import ( - CheckpointOutput, - FieldRetention, - Output, - PlaneStore, +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( RecipeRecord, - RunMetadata, SavedPlane, StepHistory, StepStats, @@ -91,6 +87,7 @@ _adopted, ) from solprop.output.snapshots import SnapshotController +from solprop.output.store import CheckpointOutput, FieldRetention, Output, PlaneStore from solprop.output.tdse_archive import TDSEArchiveWriter from solprop.stepper.multistep import Multistep from solprop.translate.problem import PropagationProblem @@ -120,11 +117,11 @@ class ServiceContext: The translated problem. request : solprop.translate.run_request.RunRequest The run's policies. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The run's record. reporter : solprop.log.ProgressReporter Where progress goes. - retention : solprop.output.schema.FieldRetention + retention : solprop.output.store.FieldRetention Which planes keep their field. resume_history : bool or None The public method-history retention choice. @@ -134,7 +131,7 @@ class ServiceContext: A live atomic runtime. tdse_archive : solprop.output.tdse_archive.TDSEArchiveWriter or None A live archive to suspend beside the optical writer. - recipe : solprop.output.schema.RecipeRecord or None + recipe : solprop.output.records.RecipeRecord or None The recipe the run was built from. start_plane : int The first save plane beyond the starting state. @@ -371,7 +368,7 @@ def _prepare_plane( ) -> tuple[SavedPlane, StoredField | None]: """Build an observation's plane, its kept field exported once for the output. - A plane store that :attr:`~solprop.output.schema.PlaneStore.takes_exports` is + A plane store that :attr:`~solprop.output.store.PlaneStore.takes_exports` is given the field exported straight into its stored form, into its own room where it has one (SEP 47 section 5.4), and the plane carries none; any other output is given the consumer field on the plane (:func:`_save_plane`). @@ -421,7 +418,7 @@ def serve( # noqa: PLR0912, PLR0915 - the commit sequence of SEP 47 section 5.3 ---------- session : solprop.backends.session.RunSession The started session. - output : solprop.output.schema.Output + output : solprop.output.store.Output Where the planes go; closed when the loop ends, whether or not it succeeded. context : ServiceContext Everything else the service needs. diff --git a/src/solprop/driver/summary.py b/src/solprop/driver/summary.py index 63778876..800a78c5 100644 --- a/src/solprop/driver/summary.py +++ b/src/solprop/driver/summary.py @@ -6,12 +6,12 @@ Every route reaches :func:`solprop.driver.run.run_problem`, which builds this summary from the translated :class:`~solprop.translate.problem.PropagationProblem`, the run's -:class:`~solprop.output.schema.RunMetadata` (whose ``configuration`` holds the physics +:class:`~solprop.output.metadata.RunMetadata` (whose ``configuration`` holds the physics objects the problem was built from: the responses, the medium, the profiles), the backend's own execution record and the output. It hands the text to the progress reporter, so it follows the ``progress`` choice exactly as the progress lines do, and records it on a plane store as the -:data:`~solprop.output.schema.RUN_SUMMARY_ANNOTATION`. +:data:`~solprop.output.metadata.RUN_SUMMARY_ANNOTATION`. The rows are descriptions for a person, not a record: everything in them is also in the run's metadata, which is what a program should read. Nothing here evaluates physics. @@ -30,7 +30,8 @@ from solprop.constants import C from solprop.log import format_duration from solprop.monitor import BoundaryCheck -from solprop.output.schema import Output, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.store import Output from solprop.stepper.multistep import Multistep from solprop.translate.problem import PropagationProblem from solprop.translate.routes import tdse_descriptor, transverse_of @@ -125,7 +126,7 @@ def run_summary( The translated problem. request : solprop.translate.run_request.RunRequest The run's request: its method, step control and planes. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The run's record; its ``configuration`` supplies what the problem holds only as compiled tables (the medium and the response models). backend : str @@ -133,7 +134,7 @@ def run_summary( execution : collections.abc.Mapping The backend's ``metadata()``, which says how it sized its thread pools and which propagator it chose. - output : solprop.output.schema.Output + output : solprop.output.store.Output Where the planes go. start_plane : int, optional Index of the first save plane beyond the loaded state; above zero the run is a diff --git a/src/solprop/modes/material.py b/src/solprop/modes/material.py index bfbe4365..02635655 100644 --- a/src/solprop/modes/material.py +++ b/src/solprop/modes/material.py @@ -40,7 +40,7 @@ type JsonValue = ( bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None ) -"""Anything :func:`solprop.output.schema.serialise` may return, and therefore +"""Anything :func:`solprop.output.serialisation.serialise` may return, and therefore anything JSON can carry. Re-exported by that module, which owns the file format.""" MEASURED_DIGITS = 6 diff --git a/src/solprop/nonlinear/gnlse.py b/src/solprop/nonlinear/gnlse.py index b8fb585e..20e46aef 100644 --- a/src/solprop/nonlinear/gnlse.py +++ b/src/solprop/nonlinear/gnlse.py @@ -571,8 +571,8 @@ class TaylorDispersion: The instance is callable and stands where a closure would. That is what makes a mode built from it **reproducible in a run's metadata**: - :func:`solprop.output.schema.serialise` records a closure as its ``repr``, which - carries the function's memory address, so a configuration holding one hashes + :func:`solprop.output.serialisation.serialise` records a closure as its ``repr``, + which carries the function's memory address, so a configuration holding one hashes differently in every process and the run could never be resumed from its own file. A frozen dataclass records its fields instead. diff --git a/src/solprop/output/__init__.py b/src/solprop/output/__init__.py index ee1ed489..c6efb330 100644 --- a/src/solprop/output/__init__.py +++ b/src/solprop/output/__init__.py @@ -5,7 +5,7 @@ """Output handlers and the metadata schema. In-memory and HDF5 outputs sharing one save protocol -(:class:`solprop.output.schema.PlaneStore`), one metadata schema and one sign convention +(:class:`solprop.output.store.PlaneStore`), one metadata schema and one sign convention on the way out: stored fields are in the optics convention and carry the attribute ``spectral_convention = "optics"`` (see ``docs/dev/decisions.md``). The manual page is ``docs/guide/results/output.md``. A run reduced plane by plane stores nothing, and gives @@ -27,35 +27,25 @@ resume, ) from solprop.output.memory import MemoryOutput -from solprop.output.resumable import Resumability, resumability -from solprop.output.schema import ( +from solprop.output.metadata import SCHEMA_VERSION, RunMetadata, resolve_metadata_write +from solprop.output.provenance import BackendRecord, GitProvenance, package_versions +from solprop.output.records import ( FIELD_LAYOUT, HISTORY_FIELDS, INTERNAL_CONVENTION, - SCHEMA_VERSION, SPECTRAL_CONVENTION, - BackendRecord, - CheckpointOutput, - GitProvenance, GridRecord, ModeRecord, NoiseRecord, - Output, - PlaneLayout, - PlaneStore, - RetainingOutput, - RunMetadata, RunOutcome, SavedPlane, - StatedInput, StepHistory, StepLimits, StepperRecord, TerminalCheckpoint, - package_versions, - resolve_metadata_write, - serialise, ) +from solprop.output.resumable import Resumability, resumability +from solprop.output.serialisation import StatedInput, serialise from solprop.output.snapshots import ( SnapshotController, SnapshotFiles, @@ -64,6 +54,13 @@ read_snapshot, restore_snapshot, ) +from solprop.output.store import ( + CheckpointOutput, + Output, + PlaneLayout, + PlaneStore, + RetainingOutput, +) from solprop.output.tdse_archive import ( TDSE_ARCHIVE_SCHEMA, TDSEArchive, diff --git a/src/solprop/output/callback.py b/src/solprop/output/callback.py index a37ee51e..b309573e 100644 --- a/src/solprop/output/callback.py +++ b/src/solprop/output/callback.py @@ -9,10 +9,10 @@ the run made it, with no copy and no conversion, so the handler costs no volume of its own. -It is an :class:`~solprop.output.schema.Output` and a -:class:`~solprop.output.schema.RetainingOutput`, and deliberately neither a -:class:`~solprop.output.schema.PlaneStore` nor a -:class:`~solprop.output.schema.CheckpointOutput`. A store's readers have nothing to read +It is an :class:`~solprop.output.store.Output` and a +:class:`~solprop.output.store.RetainingOutput`, and deliberately neither a +:class:`~solprop.output.store.PlaneStore` nor a +:class:`~solprop.output.store.CheckpointOutput`. A store's readers have nothing to read here, and its save path converts each field into a new volume. A checkpoint promises a state that ``resume=True`` can continue, and with no file there is nothing to continue, so a run neither fetches a terminal field for this handler nor says that one was @@ -27,13 +27,9 @@ from solprop._display import line from solprop.exceptions import OutputError -from solprop.output.schema import ( - Convention, - FieldRetention, - RunMetadata, - SavedPlane, - resolve_metadata_write, -) +from solprop.output.metadata import RunMetadata, resolve_metadata_write +from solprop.output.records import Convention, SavedPlane +from solprop.output.store import FieldRetention __all__ = ["CallbackOutput"] @@ -50,7 +46,7 @@ def _not_held(what: str) -> OutputError: class CallbackOutput: """Give each saved plane to a callable, and keep nothing. - The callable is given the run's own :class:`~solprop.output.schema.SavedPlane`, not + The callable is given the run's own :class:`~solprop.output.records.SavedPlane`, not a copy. Its arrays are read-only and nothing writes to them again, so the callable may keep any of them, at the cost of what it keeps; a plane it reduces and lets go costs one field volume on the host while it is held, the one the observation @@ -142,7 +138,7 @@ def open(self, metadata: RunMetadata, *, force: bool = False) -> None: The record of the run about to start. force : bool, optional Whether to replace a different record held already - (:func:`~solprop.output.schema.resolve_metadata_write`). + (:func:`~solprop.output.metadata.resolve_metadata_write`). Raises ------ @@ -162,7 +158,7 @@ def open(self, metadata: RunMetadata, *, force: bool = False) -> None: def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: """Accept the run's field rule: there is no storage for it to size. - Declares :class:`~solprop.output.schema.RetainingOutput`, which is what admits + Declares :class:`~solprop.output.store.RetainingOutput`, which is what admits field-free planes. The rule reaches the callable as each plane's ``field``. Parameters diff --git a/src/solprop/output/capability.py b/src/solprop/output/capability.py index 4ee004f9..707f6886 100644 --- a/src/solprop/output/capability.py +++ b/src/solprop/output/capability.py @@ -43,7 +43,7 @@ from types import MappingProxyType from typing import Literal -from solprop.output.schema import RunMetadata +from solprop.output.metadata import RunMetadata from solprop.stats import ( _GAS_OR_SPATIAL_NAMES, _STEP_DIAGNOSTICS, @@ -258,7 +258,7 @@ def capability_of(metadata: RunMetadata) -> BasisCapability: Parameters ---------- - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The record read back from a handler. Returns @@ -269,7 +269,7 @@ def capability_of(metadata: RunMetadata) -> BasisCapability: Examples -------- >>> from solprop.output.capability import capability_of - >>> from solprop.output.schema import _example_metadata + >>> from solprop.output.metadata import _example_metadata >>> capability_of(_example_metadata()) 'modal' """ @@ -389,7 +389,7 @@ def reading_refusal( Parameters ---------- - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The stored record. name : str The reader or accessor asked for, which the refusal names. @@ -406,7 +406,7 @@ def reading_refusal( -------- >>> from dataclasses import replace >>> from solprop.output.capability import reading_refusal - >>> from solprop.output.schema import _example_metadata + >>> from solprop.output.metadata import _example_metadata >>> reading_refusal(_example_metadata(), "get_power") is None True >>> radial = replace( @@ -498,7 +498,7 @@ def withheld_from(metadata: RunMetadata) -> tuple[str, ...]: Parameters ---------- - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The stored record. Returns diff --git a/src/solprop/output/hdf5.py b/src/solprop/output/hdf5.py index 5310f6ac..4e8f4c68 100644 --- a/src/solprop/output/hdf5.py +++ b/src/solprop/output/hdf5.py @@ -11,7 +11,7 @@ ``schema_version``, ``spectral_convention``, ``field_layout``, ``configuration_hash``, ``simulation_kind``, ``grid_kind``, ``created`` and, once a backend has executed the run, ``backend`` -- and ``meta/json`` carries the -whole :class:`solprop.output.schema.RunMetadata` as one JSON string. The metadata goes +whole :class:`solprop.output.metadata.RunMetadata` as one JSON string. The metadata goes in a *dataset* rather than an attribute because HDF5 caps an attribute in the object header at 64 kB and a serialised configuration -- a capillary with a tabulated cladding glass, a stored noise realisation -- goes past that; the identifying scalars @@ -25,7 +25,7 @@ **Preallocation and the saved-plane count.** The plane-indexed datasets are created at the first save for every plane the run reserved -(:meth:`~solprop.output.schema.PlaneStore.reserve`), with an unlimited leading axis, and +(:meth:`~solprop.output.store.PlaneStore.reserve`), with an unlimited leading axis, and grow by doubling if the run saves more; the step-history and between-planes columns grow by doubling too. The root attribute ``n_saved`` counts the planes actually written: each plane's data is written and flushed first and the count last, so a hard @@ -36,7 +36,7 @@ so a finished file holds no unused rows. **Field retention** (SEP 22). The root attribute ``fields`` says -which planes keep their field (:class:`~solprop.output.schema.FieldRetention`), and +which planes keep their field (:class:`~solprop.output.store.FieldRetention`), and ``field_shape`` the shape of one plane's field whether or not it is stored. A run that keeps every field stores ``field`` and ``noise_phase`` as above. One that keeps the named planes stores exactly those, in plane order, with a ``field_planes`` dataset of @@ -48,7 +48,7 @@ **Cache and resume.** :func:`check_cache` compares the file's configuration hash with the requested one (ADR-066): they must be equal, so resuming with different physics or numerics of the same shape is refused, by naming what changed. A file of another output -schema is not read at all (:data:`~solprop.output.schema.SCHEMA_VERSION`). +schema is not read at all (:data:`~solprop.output.metadata.SCHEMA_VERSION`). :func:`resume` returns the metadata, the last plane -- converted back to the internal convention -- and the step history. A handler built with ``append=True`` then carries on in the same file: it opens it read-write instead of replacing it, compares the @@ -79,19 +79,13 @@ from solprop._display import Displayed, line, tail from solprop.exceptions import OutputError from solprop.output._writer_lock import WriterLock -from solprop.output.schema import ( +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( HISTORY_FIELDS, STEP_STATS_INDEX, - FieldRetention, - FieldsChoice, - JsonValue, MarchSnapshot, - Output, - PlaneLayout, - PlaneStore, RecipeFile, RecipeRecord, - RunMetadata, RunOutcome, SavedPlane, StepHistory, @@ -99,6 +93,14 @@ StoredField, TerminalCheckpoint, ) +from solprop.output.serialisation import JsonValue +from solprop.output.store import ( + FieldRetention, + FieldsChoice, + Output, + PlaneLayout, + PlaneStore, +) __all__ = [ "COMPRESSIONS", @@ -638,23 +640,23 @@ class ResumeState(Displayed): Attributes ---------- - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The record of the interrupted run, including its step schedule. - plane : solprop.output.schema.SavedPlane + plane : solprop.output.records.SavedPlane The accepted terminal checkpoint when present, otherwise the last regular plane, with its field back in the **internal** FFT convention. Modal amplitudes remain in output units until the driver applies the weight at z. n_saved : int Number of regular planes stored. The next scheduled index is determined from the actual position, independently of this count. - history : solprop.output.schema.StepHistory + history : solprop.output.records.StepHistory Every attempt of the whole run so far, concatenated. - outcome : solprop.output.schema.RunOutcome or None + outcome : solprop.output.records.RunOutcome or None Completion status and cumulative call ledgers; ``None`` when the file records none. - checkpoint : solprop.output.schema.TerminalCheckpoint or None + checkpoint : solprop.output.records.TerminalCheckpoint or None Separate accepted terminal state, preferred over the last regular save. - march : solprop.output.schema.MarchSnapshot or None + march : solprop.output.records.MarchSnapshot or None The Adams march's state where the run stopped, when the file kept one there. Examples @@ -720,7 +722,7 @@ class HDF5Output(PlaneStore): """Stream a run to one HDF5 file, opened once and extended plane by plane. Reading through ``__getitem__`` and - :meth:`~solprop.output.schema.PlaneStore.fields` works while the handler is + :meth:`~solprop.output.store.PlaneStore.fields` works while the handler is open and stops when it is closed, because the arrays live in the file rather than in memory; :func:`open_file` opens a finished file for reading afterwards. @@ -759,9 +761,8 @@ class HDF5Output(PlaneStore): >>> from pathlib import Path >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import HDF5Output - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord - ... ) + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, SavedPlane, StepperRecord >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> metadata = RunMetadata( @@ -914,7 +915,7 @@ def save(self, plane: SavedPlane) -> None: def _store(self, plane: SavedPlane, exported: StoredField | None) -> None: """Store one plane and flush it to disk (:meth:`save`); refused if read-only. - An exported field (:meth:`~solprop.output.schema.PlaneStore.save_exported`) + An exported field (:meth:`~solprop.output.store.PlaneStore.save_exported`) is a new array, which h5py writes into the plane's chunk as it is: no NumPy copy, and an uncompressed chunk no copy of HDF5's own either (a compressed one is filtered through HDF5's buffer). @@ -950,7 +951,7 @@ def save_march(self, snapshot: MarchSnapshot) -> None: Parameters ---------- - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot The march's state at the last saved plane or terminal checkpoint. Raises @@ -1402,7 +1403,7 @@ def _append( - ``plane``: the plane: its field in the internal convention, or ``None`` where the field was exported straight into the stored form - (:meth:`~solprop.output.schema.PlaneStore.save_exported`) and `stored` alone + (:meth:`~solprop.output.store.PlaneStore.save_exported`) and `stored` alone holds it. A subclass whose own ``_append`` reads the plane's field does not take exports (``PlaneStore.takes_exports``). - ``stored``: that field converted to the optics convention; ``None`` on a @@ -1612,7 +1613,7 @@ def _write_annotation(file: h5py.File, name: str, text: str) -> None: def _stored_metadata(path: Path | str) -> tuple[RunMetadata, str]: """Return the record a file carries and the digest stored beside it. - - Returns: the :class:`solprop.output.schema.RunMetadata` and the + - Returns: the :class:`solprop.output.metadata.RunMetadata` and the ``configuration_hash`` attribute the writer stored, which are checked against each other. """ @@ -1668,7 +1669,7 @@ def check_cache(path: Path | str | Output, metadata: RunMetadata) -> bool: Parameters ---------- - path : pathlib.Path or str or solprop.output.schema.Output + path : pathlib.Path or str or solprop.output.store.Output The file to check; an output handler is refused by name. metadata : RunMetadata The record of the run about to start. @@ -1689,7 +1690,7 @@ def check_cache(path: Path | str | Output, metadata: RunMetadata) -> bool: Examples -------- >>> from solprop.output.hdf5 import check_cache - >>> from solprop.output.schema import _example_metadata + >>> from solprop.output.metadata import _example_metadata >>> check_cache("no-such-file.h5", _example_metadata()) False """ @@ -1749,7 +1750,7 @@ def _last_history(file: h5py.File, index: int) -> tuple[StepHistory, StepHistory """Return the whole step history and the part belonging to one plane. - Returns: the whole history and the slice, both as - :class:`solprop.output.schema.StepHistory`. + :class:`solprop.output.records.StepHistory`. """ group = _group(file, "step_history") counts = np.asarray(_dataset(group, "counts")[: index + 1], dtype=np.int64) @@ -1804,7 +1805,7 @@ def resume(path: Path | str | Output) -> ResumeState: Parameters ---------- - path : pathlib.Path or str or solprop.output.schema.Output + path : pathlib.Path or str or solprop.output.store.Output The file to read; an output handler is refused by name. Returns @@ -1826,7 +1827,8 @@ def resume(path: Path | str | Output) -> ResumeState: >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import HDF5Output >>> from solprop.output.hdf5 import resume - >>> from solprop.output.schema import SavedPlane, _example_metadata + >>> from solprop.output.metadata import _example_metadata + >>> from solprop.output.records import SavedPlane >>> metadata = _example_metadata() >>> n_omega = metadata.grid.n_omega >>> with tempfile.TemporaryDirectory() as folder: @@ -1959,9 +1961,8 @@ def open_file(path: Path | str) -> HDF5Output: >>> from pathlib import Path >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import MemoryOutput, open_file - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord - ... ) + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, SavedPlane, StepperRecord >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> memory = MemoryOutput() diff --git a/src/solprop/output/memory.py b/src/solprop/output/memory.py index a2c4d2d1..20f80418 100644 --- a/src/solprop/output/memory.py +++ b/src/solprop/output/memory.py @@ -7,16 +7,16 @@ Everything in RAM: the handler for fast, small problems and for the members of a scan, where a file per run would cost more than the run. It stores exactly what :class:`solprop.output.hdf5.HDF5Output` stores, in the same layout and the same sign -convention -- both go through :class:`solprop.output.schema.PlaneStore` -- so a test can +convention -- both go through :class:`solprop.output.store.PlaneStore` -- so a test can compare the two bitwise and :meth:`MemoryOutput.to_file` writes the file the other handler would have written. The plane-indexed arrays are allocated once, for the planes the run reserved -(:meth:`~solprop.output.schema.PlaneStore.reserve`; the run loop reserves the entrance +(:meth:`~solprop.output.store.PlaneStore.reserve`; the run loop reserves the entrance and every save plane), or for a block of :data:`INITIAL_BLOCK` planes when nothing was reserved, and grow by doubling past that rather than by concatenating on every save, which would be quadratic in the number of planes. The field block follows the run's -field retention (:meth:`~solprop.output.schema.PlaneStore.retain`): every plane, +field retention (:meth:`~solprop.output.store.PlaneStore.retain`): every plane, exactly the named planes, or none at all, in which case no field block is allocated. The step history is variable in length per plane, so it is kept as a list of pieces, concatenated when read, with a per-plane count beside it. @@ -33,16 +33,15 @@ from solprop.exceptions import OutputError from solprop.fourier import GridKind, spectrum_to_internal -from solprop.output.schema import ( +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( HISTORY_FIELDS, STEP_STATS_INDEX, - PlaneStore, - RunMetadata, SavedPlane, StepHistory, StepStats, - stored_spectrum, ) +from solprop.output.store import PlaneStore, stored_spectrum __all__ = ["INITIAL_BLOCK", "MemoryOutput"] @@ -113,7 +112,7 @@ def _step_stats_slice( class MemoryOutput(PlaneStore): """Hold a whole run in memory, in the layout an HDF5 file would use. - Reading stays available after :meth:`~solprop.output.schema.PlaneStore.close`, + Reading stays available after :meth:`~solprop.output.store.PlaneStore.close`, unlike the file handler: nothing is released, so a driver may close in a ``finally`` and a caller keep reading. @@ -121,7 +120,7 @@ class MemoryOutput(PlaneStore): ---------- block : int, optional Planes to preallocate when the run reserved none - (:meth:`~solprop.output.schema.PlaneStore.reserve`); a reserved count takes its + (:meth:`~solprop.output.store.PlaneStore.reserve`); a reserved count takes its place. The store doubles whenever it is full. Raises @@ -134,9 +133,8 @@ class MemoryOutput(PlaneStore): >>> import numpy as np >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord - ... ) + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, SavedPlane, StepperRecord >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> metadata = RunMetadata( @@ -298,8 +296,12 @@ def field_destination( -------- >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord, StoredField + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import ( + ... GridRecord, + ... SavedPlane, + ... StepperRecord, + ... StoredField, ... ) >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) @@ -357,7 +359,7 @@ def _append( - ``plane``: the plane: its field in the internal convention, or ``None`` where the field was exported straight into the stored form - (:meth:`~solprop.output.schema.PlaneStore.save_exported`) and `stored` alone + (:meth:`~solprop.output.store.PlaneStore.save_exported`) and `stored` alone holds it. A subclass whose own ``_append`` reads the plane's field does not take exports (``PlaneStore.takes_exports``). - ``stored``: that field converted to the optics convention, in its row of the @@ -504,7 +506,7 @@ def planes(self) -> list[SavedPlane]: Returns ------- list - One :class:`solprop.output.schema.SavedPlane` per saved plane, in order. + One :class:`solprop.output.records.SavedPlane` per saved plane, in order. Examples -------- @@ -578,9 +580,8 @@ def to_file( >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import HDF5Output, MemoryOutput >>> from solprop.output.hdf5 import resume - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord - ... ) + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, SavedPlane, StepperRecord >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> metadata = RunMetadata( diff --git a/src/solprop/output/metadata.py b/src/solprop/output/metadata.py new file mode 100644 index 00000000..f3022160 --- /dev/null +++ b/src/solprop/output/metadata.py @@ -0,0 +1,1222 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The run record: what produced an output file, and the resume key it is checked by. + +An output file has to answer one question years later: *what produced this?* +:class:`RunMetadata` is that answer: the grid, the modes, the serialised physics +configuration, the package versions, the git provenance of the running checkout, the +noise seed and realisation, and the stepper settings. + +**The resume key.** :meth:`RunMetadata.hashed` is the part of the record that says what +was computed: the schema version, the grid, the modes, the configuration, the noise, the +stepper settings with the stepping method when it is not the tableau (SEP 13), and the +identities and policies of the run's material manifest, and nothing else: not the +wall-clock time the run started, not the package versions, and not the git provenance. +:meth:`RunMetadata.configuration_hash` is a SHA-256 of its canonical JSON rather than +:func:`hash`, so it is stable across processes; it is stamped in every file and is the +exact identity a resume, a suspended writer, a snapshot and a scan row are checked by +(ADR-066): a resume with different physics or numerics of the same shape is refused, +naming the fields that changed (:meth:`RunMetadata.summarise_differences`). A record +read from a file is compared as it was written. + +The manual page is ``docs/guide/results/provenance.md``. +""" + +import dataclasses +import functools +import hashlib +import json +import logging +import re +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, cast + +import numpy as np + +from solprop._display import Displayed, engineering, line +from solprop.exceptions import MaterialError, OutputError +from solprop.grid import EnvelopeGrid +from solprop.modes.material import JsonValue +from solprop.modes.protocol import TransverseMeasure +from solprop.output.provenance import BackendRecord, GitProvenance, package_versions +from solprop.output.records import ( + FIELD_LAYOUT, + SPECTRAL_CONVENTION, + DefaultsResolution, + GridRecord, + ModeRecord, + NoiseRecord, + NoiseResolution, + SimulationKind, + StepperRecord, +) +from solprop.output.serialisation import ( + _RECORD_ARRAY, + _ArrayReference, + _canonical_json, + _rebuild, + _resolved, + _stored_entry, + serialise, +) +from solprop.physdata.selection import MaterialManifest, hashed_document + +__all__ = [ + "INTERPOLATED_RESUMES_ANNOTATION", + "PEAK_FIELDS_ANNOTATION", + "RUN_SUMMARY_ANNOTATION", + "SCHEMA_VERSION", + "RunMetadata", + "resolve_metadata_write", +] + +logger = logging.getLogger(__name__) + +_DIFFERENCES_SHOWN = 4 +"""How many changed fields a refusal names in each part of the record before it counts +the rest of that part.""" + +_PATH_SEPARATOR = re.compile(r"[.\[]") +"""What ends a changed path's top-level key, the part of the record it belongs to.""" + +_ELEMENT_INDICES = re.compile(r"(?:\[\d+\])+$") +"""The trailing indices of a changed path into an array's element.""" + +_ARRAY_PART = re.compile(r"\.__(?:ndarray|imaginary)__$") +"""The key of an array record's real or imaginary part, dropped from its stem.""" + +SCHEMA_VERSION = 2 +"""Version of the saved-output layout; incremented when a stored field changes meaning. + +Schema 1 is the first released layout, 0.1.0's: the record types of this module, written +by :class:`solprop.output.memory.MemoryOutput` and +:class:`solprop.output.hdf5.HDF5Output` as ``docs/guide/results/output.md`` describes +them. Schema 2 records ``DataPulse.spectral_phase``. A record that declares any other +version is refused by name (:class:`RunMetadata`): files written by an earlier release +or by development versions of solprop, which declared output schemas up to 28, are not +read, and nor is a file from a later release. Read such a file with the solprop version +that wrote it. +""" + +PEAK_FIELDS_ANNOTATION = "material_peak_fields" +"""Name of the post-run annotation holding the material manifest's peak-field report. + +A list with one plain mapping per population with an optmat ionisation rate: +the largest field it reached against its model's maximum field, and the energy per +event there against the constant the run paid +(:class:`solprop.nonlinear.optmat_rates.PeakFieldReport`). Written by +:meth:`solprop.driver.simulation.Simulation.run` when a run closes, outside the +configuration hash (SEP 3, Q13). +""" + +INTERPOLATED_RESUMES_ANNOTATION = "interpolated_resumes" +"""Name of the post-run annotation listing where a dense run was resumed, m. + +A list of the positions of the interpolated save planes a run observed densely was +continued from with ``resume="interpolated"``: each continuation started afresh from a +field interpolated inside a step, not a state the march passed through (SEP 14). +Written by :meth:`solprop.driver.simulation.Simulation.run`, outside the configuration +hash. +""" + +RUN_SUMMARY_ANNOTATION = "run_summary" +"""Name of the annotation holding the start-of-run summaries of a plane store. + +A list with one mapping per start, ``{"title": str, "rows": [[key, value], ...]}``: a +run that is resumed adds the summary of its continuation to the first one's. Written +when a run starts, not when it closes, and outside the configuration hash. +""" + + +def _differences( + left: JsonValue, right: JsonValue, path: str, found: list[str] +) -> None: + """Collect the dotted paths at which two serialised structures differ. + + - ``right``: the second. + """ + if isinstance(left, dict) and isinstance(right, dict): + if _RECORD_ARRAY in left and _RECORD_ARRAY in right: + # One entry, whatever changed in it: the array has no text to walk. + if dict(left) != dict(right): + found.append(path) + return + for key in sorted(set(left) | set(right)): + child = f"{path}.{key}" if path else key + if key not in left or key not in right: + found.append(child) + else: + _differences(left[key], right[key], child, found) + return + if isinstance(left, list) and isinstance(right, list): + if len(left) != len(right): + found.append(f"{path}[length]") + return + for index, (one, other) in enumerate(zip(left, right, strict=True)): + _differences(one, other, f"{path}[{index}]", found) + return + if _canonical_json(left) != _canonical_json(right): + found.append(path) + + +def _refuse_schema(declared: object) -> None: + """Refuse a record of any output schema but :data:`SCHEMA_VERSION`. + + One rule for every other number: an earlier release wrote a lower output schema, a + development version of solprop wrote schemas up to 28, and a later release may write + a higher one, and this release reads none of them (ADR-058). + """ + if type(declared) is int and declared == SCHEMA_VERSION: + return + msg = ( + f"this record declares output schema_version {declared!r}, and this release " + f"of solprop reads output schema {SCHEMA_VERSION} only: a file written by an " + "earlier release, by a development version of solprop or by a later release is " + "not read; read it with the solprop version that wrote it" + ) + raise OutputError(msg) + + +_HASHED_KEYS: tuple[str, ...] = ( + "schema_version", + "simulation_kind", + "grid", + "modes", + "configuration", + "noise", + "stepper", + "spatial", + "power_convention", + "modal", + "materials", + "initial_z", + "tdse", + "quadrature", +) +"""The entries of :meth:`RunMetadata.hashed`, in the order it builds them.""" + + +def _stored_hashed(document: Mapping[str, JsonValue]) -> dict[str, JsonValue]: + """Return the hashed part of a stored record, as the stored text holds it. + + The entries :meth:`RunMetadata.hashed` covers, taken from the document rather than + from the objects rebuilt from it: the general modal identity without its accessor + list (:func:`_hashed_modal`), and the material manifest's hashed part + (:func:`solprop.physdata.selection.hashed_document`). An entry the document lacks + stays absent, so the record digests to the stamp its writer computed. + """ + hashed = {name: document[name] for name in _HASHED_KEYS if name in document} + modal = hashed.get("modal") + if isinstance(modal, dict): + hashed["modal"] = { + key: value for key, value in modal.items() if key != "accessors" + } + if "materials" in hashed: + materials = hashed["materials"] + hashed["materials"] = cast( + "JsonValue", + hashed_document( + None if materials is None else cast("dict[str, object]", materials) + ), + ) + return hashed + + +def _hashed_modal(modal: Mapping[str, object] | None) -> JsonValue: + """Return the general modal identity as the resume key sees it. + + Everything in the record except ``accessors``. Every other entry says what + *was computed* -- which guide, which tables, which basis, which responses, how + far from orthonormal it measured -- and a resume must refuse when one of them + moves. ``accessors`` says what *this package version can compute*, which is a + property of the build and not of the run: leaving it in the key would make + every accessor this package gains invalidate every general modal file ever + written, for a capability those files **gain** rather than lose. The list stays in + the record for a reader, and the resume door checks it as a **subset** instead + (:func:`solprop.output.hdf5.check_cache`). + + - ``modal``: the identity, as :func:`solprop.translate.general.modal_identity` + writes it, or ``None`` for a run that is not a general modal one. + - Returns: the serialised identity without ``accessors``, or ``None``. + """ + if modal is None: + return None + return serialise({key: value for key, value in modal.items() if key != "accessors"}) + + +def _guide_text(guide: object) -> str: + """Return the one printed line describing the guide a general run was built on. + + - Returns: the provider's model, its region count and the resolution its reference + was sampled at, or ``'none'`` when the entry is missing or malformed. + """ + if not isinstance(guide, Mapping): + return "none" + provider = guide.get("provider") + sampling = guide.get("sampling") + regions = guide.get("regions") + model = provider.get("model") if isinstance(provider, Mapping) else "unnamed" + count = len(regions) if isinstance(regions, Sequence) else 0 + if not isinstance(sampling, Mapping): + return f"{model}, {count} region(s)" + return ( + f"{model}, {count} region(s), {sampling.get('n_knots')} knots on " + f"{sampling.get('n_nodes')} nodes, profile error " + f"{float(cast('float', sampling.get('profile_error', float('nan')))):.3e}" + ) + + +def _tables_text(tables: object) -> str: + """Return the one printed line describing a general run's transform tables. + + The ``model_id`` of a general modal reference is the provider's whole physical + identity as a JSON document -- 412 characters on the slice's one-channel + fixture, and longer with more channels -- so printing it filled the row with + the first eighty characters of a serialised mapping and said nothing. What a + reader wants from this row is the gauge, the numeric policy and something short + that two files can be compared by, which is what a digest is for. The full + document is in ``metadata.modal['tables']['model_id']``. + + - Returns: a 12-hex digest of the model identity, the gauge and the policy, or + ``'none'`` when the entry is missing or malformed. + """ + if not isinstance(tables, Mapping): + return "none" + model = tables.get("model_id") + digest = ( + hashlib.sha256(str(model).encode("utf-8")).hexdigest()[:12] + if model is not None + else "unnamed" + ) + # The digest first: the row is truncated to the block's width, and it is the + # entry two files are compared by. + return ( + f"model {digest}, gauge {tables.get('gauge_id')}, " + f"policy {tables.get('policy_id')}" + ) + + +def _responses_text(responses: object) -> str: + """Return the one printed line describing a general run's regional responses. + + - ``responses``: the ``responses`` entry of a general modal identity, or ``None`` + for a guide whose regions are all linear. + - Returns: the compiled model, the responding region count and each region's + declared susceptibility, or ``'none'`` when no region responds. + """ + if not isinstance(responses, Mapping): + return "none" + chi3 = responses.get("chi3") + values = ( + ", ".join(f"{float(one):.3e}" for one in chi3) + if isinstance(chi3, Sequence) and not isinstance(chi3, str) + else "unrecorded" + ) + return ( + f"{responses.get('model_id')}, {responses.get('policy_id')}, " + f"{responses.get('n_responses')} region(s), chi3 {values}" + ) + + +def _flow_text(flow: object) -> str: + """Return the printed summary of a coupled-flow entry, or that there is none.""" + if not isinstance(flow, Mapping): + return "diagonal" + ratios = [ + float(cast("float", flow.get(name, 0.0))) + for name in ("transverse_ratio", "vacuum_ratio", "quasi_static") + ] + methods = flow.get("section_methods") + named = ( + ", ".join(str(one) for one in methods) + if isinstance(methods, Sequence) and not isinstance(methods, str) + else "none" + ) + conditioning = float(cast("float", flow.get("inverse_condition_bound", 1.0))) + return ( + f"{flow.get('model')}, {named}, worst ratio {max(ratios):.3e}, " + f"conditioning {conditioning:.3e}" + ) + + +def _modal_rows(modal: Mapping[str, object] | None) -> list[tuple[str, object]]: + """Return the printed rows of a general modal identity, or none at all. + + Every field of :attr:`RunMetadata.modal` appears in one of these rows, + including the ``flow`` entry of a coherent wall, + so that a printed record says which guide, which table and which basis produced + the file; the rows are absent altogether for a route that has no such identity. + + - ``modal``: the identity, or ``None`` for a route that declares none. + """ + if modal is None: + return [] + tables = modal.get("tables") + metric = modal.get("metric") + guide = modal.get("guide") + band = modal.get("active_band") + channels = modal.get("channels") + names = ( + ", ".join(str(one.get("display_label")) for one in channels) + if isinstance(channels, Sequence) and not isinstance(channels, str) + else "none" + ) + published = modal.get("accessors") + accessors = ( + cast("Sequence[object]", published) + if isinstance(published, Sequence) and not isinstance(published, str) + else None + ) + return [ + ("basis", f"{modal.get('basis')}, {modal.get('n_components')} components"), + ("channels", names), + ("channel count", modal.get("n_channels")), + ("tables", _tables_text(tables)), + ( + "metric", + f"{metric.get('kind')}, defect " + f"{float(metric.get('defect', float('nan'))):.3e}, certificate " + f"{metric.get('certificate_id')}" + if isinstance(metric, Mapping) + else "none", + ), + ("responses", _responses_text(modal.get("responses"))), + ("guide", _guide_text(guide)), + ( + "active band", + f"{engineering(float(band[0]), 'rad/s')} to " + f"{engineering(float(band[1]), 'rad/s')}" + if isinstance(band, Sequence) and len(band) == 2 + else "none", + ), + ("frame", modal.get("frame")), + ("power weight", modal.get("power_weight")), + ("flow", _flow_text(modal.get("flow"))), + ("noise basis", modal.get("noise_reference_basis") or "none"), + ( + "accessors", + # The count first: the row is truncated to the block's width, and a + # reader who cannot see the last name can still see how many there are + # and read the list off `metadata.modal['accessors']`. + f"{len(accessors)}: {', '.join(str(one) for one in accessors)}" + if accessors is not None + else "unrestricted", + ), + ] + + +def _material_rows(manifest: MaterialManifest) -> list[tuple[str, object]]: + """Return the printed row of a material manifest, or none for an empty one.""" + if manifest.empty: + return [] + names = ", ".join(sorted({record.name for record in manifest.records})) or "none" + return [("materials", f"optmat {manifest.optmat_version}: {names}")] + + +@dataclass(frozen=True, eq=False, kw_only=True) +class RunMetadata(Displayed): + """Everything needed to say what produced a file, and to refuse a wrong resume. + + Equality is by identity (``eq=False``); two runs are the same run when their + :meth:`configuration_hash` agree, which is what a resume actually asks. + + Attributes + ---------- + grid : GridRecord + The grid the run was computed on. + modes : tuple + One :class:`~solprop.output.records.ModeRecord` per propagated mode, in state + order. + simulation_kind : {'mode_averaged', 'multimode', 'radial', 'free', 'slab'} + Which transform the run used. + configuration : dict + The physics objects that built the run -- responses, profiles, pulses, + boundaries -- as :func:`~solprop.output.serialisation.serialise` records them. + Nothing is silently dropped: a callable is present as its ``repr`` with + ``reproducible: False``. + stepper : StepperRecord + The solver settings and the schedule taken. + noise : NoiseRecord or None + The shot-noise realisation, or ``None`` for a noiseless run. + noise_resolution : NoiseResolution or None + How the noise model was chosen (SEP 37): the requested and the resolved + model and any fallback's reason. Outside the hash; ``None`` when the record's + builder did not say, as for a record built by hand. + defaults : DefaultsResolution or None + Which of the integrator, the controller and the collar scheme took the + route's default. Outside the hash; ``None`` when the record's builder did not + say. + backend : BackendRecord or None + What executed the run, written by the driver from the backend's own + ``metadata()``; ``None`` for a record built before a backend existed. Outside + the hash, like the versions and the provenance beside it: a run continued on + the other backend is the same run. + versions : dict + Package versions, from :func:`~solprop.output.provenance.package_versions`. + Outside the hash. + git : GitProvenance or None + Provenance of the running checkout, or ``None``. Outside the hash. + created : str + When the run started, ISO 8601 in UTC. Outside the hash. + schema_version : int + Version of this record layout, :data:`SCHEMA_VERSION`; any other is refused. + spectral_convention : str + Sign convention of the stored field, always ``'optics'``. + field_layout : str + Axis order of the stored field. + spatial : collections.abc.Mapping or None + Spatial geometry, axis meanings, integration measures, field units and the + plane measure (``flux_model`` and ``flux_plan``, SEP 34). Required for the + radial, free and slab routes and ``None`` for modal runs. Included in the + resume key. + modal : collections.abc.Mapping or None + The identity of a **general modal guide**: the ordered channel labels, the + table, metric, response and provider identities, the sampling the reference + is rebuilt from, the gauge, the frame, the active band, the noise reference + basis and the accessors this basis admits, as + :func:`solprop.translate.general.modal_identity` writes it. ``None`` for + every other route. Included in the resume key, less the accessor list, so + that a run continued against a different guide, a different table or a + reordered mode axis is refused by the field that changed + (:mod:`solprop.output.capability`). + materials : solprop.physdata.selection.MaterialManifest + The run's **material manifest**: the optmat version, the + identity of every optmat model the run resolved, its species, compositions + and solids, the policies in force and what was measured about them. Empty + for a run of solprop's own materials. Its identities and policies are in the + resume key (:meth:`~solprop.physdata.selection.MaterialManifest.hashed`), so + a run continued with another model, policy or optmat version is refused by + the entry that changed. + initial_z : float + Absolute launch position in metres; zero for a run launched at the entrance. + Included in the resume key. + tdse : collections.abc.Mapping or None + Resolved scientific atomic descriptor, included in the resume key; ``None`` + for a run without a TDSE response. Provider execution and device facts + belong to the backend record. + quadrature : collections.abc.Mapping or None + The transverse rule of a resolved modal run, as + :meth:`solprop.modes.quadrature.TransverseQuadrature.record` writes it: the + family, the counts, the nodes and the physical weights, in the resume key + (SEP 19). ``None`` for the mode-averaged and spatial routes. + reference : collections.abc.Mapping or None + The plane-reference record (SEP 31): ``carrier``, the + grid's reference frequency in rad/s; ``origin``, the reference of the run's + path origin (phase, delay, calibrated), inherited from a parent plane for a + run launched from one; ``parent``, that parent's provenance or ``None``; + ``frame``, the frame integral of + :func:`solprop.translate.coupling.frame_record`, or ``None`` for an operator + supplied without frame tables; and ``basis``, the route and the basis + identity (:func:`solprop.coupling.plane.basis_identity`) at each planned plane + ``z``, the launch plane first. :func:`solprop.coupling.plane.reference_at` + rebuilds any saved plane's reference from it. Derived numbers, so outside + the hash; the inherited origin enters it through + ``configuration["coupling"]``. Every run records one; ``None`` on a record + built without it, as by hand, whose planes cannot be exchanged + (:func:`solprop.processing.planes.field_plane`). + transverse : collections.abc.Mapping or None + A free-space run's resolved transverse boundary (SEP 40), as + :meth:`solprop.spatial_absorber.SpatialAbsorberDesign.record` writes it: the + safety ramp's start and strength, and a collar's strength, band, threshold + and step limit. Derived numbers, so outside the hash; the specification is in + ``configuration["spatial_absorber"]`` where one was given. ``None`` without + a transverse boundary. + + Examples + -------- + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, StepperRecord + >>> from solprop.stepper import DOPRI5, PIController + >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + >>> metadata = RunMetadata( + ... grid=GridRecord.from_grid(grid), + ... modes=(), + ... simulation_kind="mode_averaged", + ... configuration={"gamma": 0.11}, + ... stepper=StepperRecord.from_settings( + ... DOPRI5, PIController(rtol=1e-8, atol=0.0) + ... ), + ... ) + >>> len(metadata.configuration_hash()) + 64 + >>> metadata.spectral_convention + 'optics' + """ + + grid: GridRecord + modes: tuple[ModeRecord, ...] + simulation_kind: SimulationKind + configuration: Mapping[str, object] + stepper: StepperRecord + noise: NoiseRecord | None = None + backend: BackendRecord | None = None + versions: Mapping[str, str] = dataclasses.field(default_factory=package_versions) + git: GitProvenance | None = dataclasses.field(default_factory=GitProvenance.detect) + created: str = "" + schema_version: int = SCHEMA_VERSION + spectral_convention: str = SPECTRAL_CONVENTION + field_layout: str = FIELD_LAYOUT + spatial: Mapping[str, object] | None = None + modal: Mapping[str, object] | None = None + materials: MaterialManifest = dataclasses.field(default_factory=MaterialManifest) + initial_z: float = 0.0 + tdse: Mapping[str, object] | None = None + quadrature: Mapping[str, object] | None = None + reference: Mapping[str, object] | None = None + transverse: Mapping[str, object] | None = None + noise_resolution: NoiseResolution | None = None + defaults: DefaultsResolution | None = None + _stored: dict[str, JsonValue] | None = dataclasses.field( + default=None, init=False, repr=False, compare=False, metadata={"record": False} + ) + _stored_arrays: dict[str, np.ndarray] | None = dataclasses.field( + default=None, init=False, repr=False, compare=False, metadata={"record": False} + ) + + def __repr__(self) -> str: + """Return a one-line description: what kind of run, on what, made when. + + Returns + ------- + str + For example ``RunMetadata(modal, field grid of 8192, 2 modes, native, + 2026-09-17T12:00:00+00:00)``. + """ + backend = "no backend" if self.backend is None else self.backend.name + return line( + "RunMetadata", + [ + ("", self.simulation_kind), + ("", f"{self.grid.kind} grid of {self.grid.n_t}"), + ("", f"{len(self.modes)} modes"), + ("", backend), + ("", self.created or "undated"), + ], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + grid = self.grid + kinds = [mode.kind.rsplit(".", 1)[-1] for mode in self.modes] + versions = ", ".join(f"{name} {value}" for name, value in self.versions.items()) + return f"RunMetadata: a {self.simulation_kind} run", [ + ( + "grid", + f"{grid.kind}, {grid.n_t} samples, reference " + f"{engineering(grid.reference_wavelength, 'm')}, length " + f"{engineering(grid.length, 'm')}", + ), + ("modes", ", ".join(kinds) or "none"), + ("backend", "none recorded" if self.backend is None else self.backend.name), + ("created", self.created or "undated"), + ( + "convention", + f"{self.spectral_convention} spectra, {self.power_convention} " + f"amplitudes, schema {self.schema_version}", + ), + ("versions", versions), + ("configuration", self.configuration_hash()), + *_modal_rows(self.modal), + *_material_rows(self.materials), + ] + + def __post_init__(self) -> None: + """Refuse another schema, stamp the creation time and adopt plain copies. + + Raises + ------ + OutputError + If the record declares an output schema other than :data:`SCHEMA_VERSION`, + if `initial_z` is not finite and nonnegative, or if a spatial record is + missing or names no plane measure. + """ + _refuse_schema(self.schema_version) + if not self.created: + object.__setattr__(self, "created", datetime.now(UTC).isoformat()) + object.__setattr__(self, "modes", tuple(self.modes)) + object.__setattr__(self, "configuration", dict(self.configuration)) + object.__setattr__(self, "versions", dict(self.versions)) + for name in ( + "spatial", + "modal", + "reference", + "tdse", + "quadrature", + "transverse", + ): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, dict(value)) + if not np.isfinite(self.initial_z) or self.initial_z < 0: + raise OutputError("initial_z must be finite and nonnegative") + if self.simulation_kind in ("radial", "free", "slab") and self.spatial is None: + msg = "spatial metadata requires a spatial geometry record" + raise OutputError(msg) + if self.spatial is not None and "flux_model" not in self.spatial: + msg = ( + "spatial metadata must name its flux_model and carry its flux_plan " + "(SEP 34)" + ) + raise OutputError(msg) + + @property + def transverse_measure(self) -> TransverseMeasure: # numpydoc ignore=RT01 + """What the run's transverse integrals measure. + + A run on modes uniform in y (:class:`solprop.modes.slab.SlabMode`) records + ``configuration["transverse_measure"] = "per_unit_width"``: its powers are + W/m, its energies J/m and its intensities still W/m². Every other run measures + an area. The key sits in the free-form configuration and is written only + where it is not the default. + + ``"area"`` or ``"per_unit_width"``. + + Raises + ------ + OutputError + If the stored value is neither. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> _example_metadata().transverse_measure + 'area' + """ + value = self.configuration.get("transverse_measure", "area") + if value not in ("area", "per_unit_width"): + msg = ( + f"configuration.transverse_measure is {value!r}; a run measures " + "'area' or 'per_unit_width'" + ) + raise OutputError(msg) + return cast("TransverseMeasure", value) + + @property + def power_convention(self) -> str: # numpydoc ignore=RT01 + """Amplitude label of the stored field. + + Always ``"poynting"``: the interface modal amplitude, whose squared + modulus is the true power in watts (``docs/dev/conventions.md`` §4). + """ + return "poynting" + + def hashed(self) -> dict[str, JsonValue]: + """Return the part of the record the resume key is taken over. + + Everything that describes *what was computed*, and nothing that describes + *when* or *with which build*: the wall-clock time, the package versions and the + git provenance are excluded, so that a resume survives an unrelated commit or a + dirty working tree, as long as they move no number the record holds; a new + library or platform may move one, and the resume is then refused. + ``modal.accessors`` is excluded for the same reason -- it is what this build can + compute, not what this run did (``_hashed_modal``). + + Returns + ------- + dict + The serialised schema version, grid, modes, simulation kind, configuration, + noise and stepper settings, the spatial record, the power convention, the + general modal identity, the material manifest's identities and policies, + the launch position, the TDSE descriptor and the transverse quadrature. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> sorted(_example_metadata().hashed())[:4] + ['configuration', 'grid', 'initial_z', 'materials'] + """ + return dict(self._hashed_record) + + @functools.cached_property + def _hashed_record(self) -> dict[str, JsonValue]: + """The serialised hashed part, built once per record (:meth:`hashed`). + + A run asks for it several times -- the resume key twice and the stored JSON + once -- and serialising the configuration was most of an output's opening + (SEP 25 section 6.3). The record is frozen, so the first answer stands; + :meth:`hashed` hands out a shallow copy, which its callers extend. + """ + if self._stored is not None: + return self._stored + return { + "schema_version": self.schema_version, + "simulation_kind": self.simulation_kind, + "grid": serialise(self.grid), + "modes": serialise(list(self.modes)), + "configuration": serialise(dict(self.configuration)), + "noise": serialise(self.noise), + "stepper": serialise(self.stepper), + "spatial": serialise(self.spatial), + "power_convention": self.power_convention, + # All of the general-guide identity but the accessor list, which is the + # build's capability and not the run's description (_hashed_modal). + "modal": _hashed_modal(self.modal), + # The material manifest's identities and policies; a run of solprop's + # own materials keys on `None`. + "materials": serialise(self.materials.hashed()), + "initial_z": self.initial_z, + "tdse": serialise(self.tdse), + "quadrature": serialise(self.quadrature), + } + + def configuration_hash(self) -> str: + """Return the resume key: a digest of everything that was computed. + + A SHA-256 of the canonical JSON of :meth:`hashed`, so it is stable across + processes -- unlike :func:`hash`, which is salted per interpreter. It covers the + physics and the numerics, and not only the statistic names and the state shape, + so a resume continues exactly when the two hashes are equal (ADR-066). + + Returns + ------- + str + Sixty-four lowercase hexadecimal characters. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> metadata = _example_metadata() + >>> metadata.configuration_hash() == metadata.configuration_hash() + True + """ + return self._configuration_digest + + @functools.cached_property + def _configuration_digest(self) -> str: + """The resume key, computed once per record (:meth:`configuration_hash`).""" + text = _canonical_json(self._hashed_record) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def with_backend(self, backend: BackendRecord) -> "RunMetadata": + """Return this record with a run's execution facts, its resume key kept. + + The backend record is outside the hash, so the hashed part and the resume key + already computed stand: a run opening its output does not serialise and digest + its configuration a second time, which cost a capillary run 30 ms and a large + launch seconds (solprop issue #347). + + Parameters + ---------- + backend : BackendRecord + The execution facts of the prepared backend. + + Returns + ------- + RunMetadata + A copy of this record with `backend` in place of its own. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> from solprop.output.provenance import BackendRecord + >>> metadata = _example_metadata() + >>> backend = BackendRecord(name="numpy_ref", propagator_mode="tabulate") + >>> executed = metadata.with_backend(backend) + >>> executed.backend.name + 'numpy_ref' + >>> executed.configuration_hash() == metadata.configuration_hash() + True + """ + replaced = dataclasses.replace(self, backend=backend) + # functools.cached_property stores in the instance dictionary, past the + # frozen dataclass's __setattr__. + for name in ("_hashed_record", "_configuration_digest"): + if name in self.__dict__: + replaced.__dict__[name] = self.__dict__[name] + return replaced + + def differences(self, other: "RunMetadata") -> tuple[str, ...]: + """Return the dotted paths at which two records describe different runs. + + This is what a refused resume names, so that the reason is the field that + changed rather than "the hash does not agree". + + Parameters + ---------- + other : RunMetadata + The record to compare against. + + Returns + ------- + tuple + Dotted paths into :meth:`hashed`, in depth-first order; empty when the two + describe the same run. + + Examples + -------- + >>> import dataclasses + >>> from solprop.output.metadata import _example_metadata + >>> one = _example_metadata() + >>> two = dataclasses.replace(one, configuration={"gamma": 0.22}) + >>> one.differences(two) + ('configuration.gamma',) + """ + found: list[str] = [] + _differences(self.hashed(), other.hashed(), "", found) + return tuple(found) + + def summarise_differences( + self, + other: "RunMetadata", + *, + shown: int = _DIFFERENCES_SHOWN, + ) -> str: + """Say how two records differ, briefly enough to read in a refusal. + + The paths of :meth:`differences`, with every array's element paths grouped + under the array's name and counted. Every part of the record that changed + (``configuration``, ``materials``, ``noise``, ``schema_version``, ...) is + named, with its first `shown` entries and a count of the rest, so that no + cause is hidden behind another part's many entries. Where the noise differs + and this record's realisation was drawn from a known seed, the summary says + which seed draws it again. + + Parameters + ---------- + other : RunMetadata + The record to compare against; this one is the stored record. + shown : int, optional + How many entries to name in each part of the record. + + Returns + ------- + str + ``"these fields changed: ..."``, empty of entries when the two describe + the same run. + + Examples + -------- + >>> import dataclasses + >>> from solprop.output.metadata import _example_metadata + >>> one = _example_metadata() + >>> two = dataclasses.replace( + ... one, configuration={"gamma": 0.22, "betas": [1.0, 2.0, 3.0]} + ... ) + >>> one.summarise_differences(two) + 'these fields changed: configuration.betas, configuration.gamma' + >>> three = dataclasses.replace(one, configuration={"gamma": [0.1, 0.2, 0.3]}) + >>> three.summarise_differences( + ... dataclasses.replace(one, configuration={"gamma": [0.4, 0.5, 0.6]}) + ... ) + 'these fields changed: configuration.gamma (3 elements)' + """ + # An array is recorded as its real and imaginary parts' nested lists, so an + # element is a stem and its trailing indices, counted once for both parts. + groups: dict[str, dict[str, str]] = {} + for path in self.differences(other): + container = _ELEMENT_INDICES.sub("", path) + stem = _ARRAY_PART.sub("", container) + groups.setdefault(stem, {}).setdefault(path[len(container) :], path) + parts: dict[str, list[str]] = {} + for stem, elements in groups.items(): + part = _PATH_SEPARATOR.split(stem, maxsplit=1)[0] + parts.setdefault(part, []).append( + next(iter(elements.values())) + if len(elements) == 1 + else f"{stem} ({len(elements)} elements)" + ) + named: list[str] = [] + for part, entries in parts.items(): + named.extend(entries[:shown]) + if len(entries) > shown: + named[-1] += f" (and {len(entries) - shown} more in {part})" + text = ", ".join(named) + stored = self.noise + if "noise" in parts and stored is not None and stored.seed is not None: + text += ( + f"; the stored run drew its {stored.model} noise from seed " + f"{stored.seed}: pass seed={stored.seed} to draw it again" + ) + return f"these fields changed: {text}" + + def record_arrays(self) -> dict[str, np.ndarray]: + """Return the arrays stored beside the record, by digest. + + What :meth:`to_json`'s references name: each numeric array of more than + :data:`~solprop.output.serialisation.RECORD_ARRAY_LIMIT` elements. A store keeps + them with the document, and :meth:`from_json` takes them back. + + Returns + ------- + dict + The arrays, not copied, each by the digest of its bytes with its dtype and + shape. + + Examples + -------- + >>> import dataclasses + >>> import numpy as np + >>> from solprop.output.metadata import _example_metadata + >>> metadata = dataclasses.replace( + ... _example_metadata(), configuration={"rate": np.zeros(5000)} + ... ) + >>> [array.size for array in metadata.record_arrays().values()] + [5000] + """ + found = dict(self._stored_arrays or {}) + + def _walk(value: object) -> None: + """Collect the arrays of a serialised structure.""" + if isinstance(value, _ArrayReference): + found[str(value[_RECORD_ARRAY])] = value.array + elif isinstance(value, dict): + for item in value.values(): + _walk(item) + elif isinstance(value, list): + for item in value: + _walk(item) + + _walk(self._document) + return found + + def to_json(self) -> str: + """Return the whole record as one JSON document. + + An array of more than :data:`~solprop.output.serialisation.RECORD_ARRAY_LIMIT` + elements is a reference to what :meth:`record_arrays` holds. + + Returns + ------- + str + Canonical JSON carrying the hashed part plus the whole general modal + identity, whose published accessor list the hashed part leaves out, the + whole material manifest, the plane-reference record, the noise model's + resolution, the conventions, the backend record, the versions, the git + provenance and the creation time. + + Examples + -------- + >>> import json + >>> from solprop.output.metadata import _example_metadata + >>> sorted(json.loads(_example_metadata().to_json()))[:3] + ['backend', 'configuration', 'created'] + """ + return _canonical_json(self._document) + + @functools.cached_property + def _document(self) -> dict[str, JsonValue]: + """The whole serialised record, built once per record (:meth:`to_json`). + + - Returns: the hashed part and what is stored beside it, JSON-ready. + """ + document = self.hashed() + # The whole identity, including the accessor list `hashed` leaves out: what a + # file records and what its resume key is taken over are two different + # questions, and the published accessors answer only the first + # (:func:`_hashed_modal`). + document["modal"] = serialise(self.modal) + # The whole manifest, measurements included, beside its hashed part. + document["materials"] = serialise(self.materials.to_dict()) + # Derived from the hashed problem, so stored beside the key, not in it: a + # resume on another build may evaluate the frame to a different last bit. + document["reference"] = serialise(self.reference) + # The transverse boundary's derived values (SEP 40), from a root-finder: + # beside the key, so that a resume on another build is not refused. + document["transverse"] = serialise(self.transverse) + # How the model was chosen, beside the key: the resolved model is in the + # hashed noise record already, and a request is not part of the physics. + document["noise_resolution"] = serialise(self.noise_resolution) + # Which numerics took the route's default, beside the key for the same reason. + document["defaults"] = serialise(self.defaults) + document["spectral_convention"] = self.spectral_convention + document["field_layout"] = self.field_layout + document["backend"] = serialise(self.backend) + document["versions"] = serialise(dict(self.versions)) + document["git"] = serialise(self.git) + document["created"] = self.created + return document + + @classmethod + def from_json( + cls, + text: str, + arrays: Mapping[str, object] | None = None, + ) -> "RunMetadata": + """Return the record a JSON document describes. + + The configuration comes back as the plain structures + :func:`~solprop.output.serialisation.serialise` made of it, not as the physics + objects: a callable was recorded by ``repr`` and cannot be rebuilt. That is + enough for the hash, which is what a resume compares. + + Parameters + ---------- + text : str + A document produced by :meth:`to_json`. + arrays : collections.abc.Mapping or None, optional + What :meth:`record_arrays` gave for it, by digest, or an HDF5 group holding + them. Each comes back in place of its reference, its digest checked. + + Returns + ------- + RunMetadata + The record, hashing identically to the one that produced ``text``. + + Raises + ------ + OutputError + If the document is not valid JSON, declares an output schema other than + :data:`SCHEMA_VERSION`, a required field is missing, or an array it names + is absent from `arrays` or does not have its digest. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> metadata = _example_metadata() + >>> back = RunMetadata.from_json(metadata.to_json()) + >>> back.configuration_hash() == metadata.configuration_hash() + True + """ + try: + document = json.loads(text) + except json.JSONDecodeError as error: + msg = f"stored metadata is not valid JSON: {error}" + raise OutputError(msg) from error + if not isinstance(document, dict) or "schema_version" not in document: + msg = "stored metadata is missing or malformed: no schema_version" + raise OutputError(msg) + # The version first: a file of another schema is refused by its version, + # not by the first field its layout lacks. + _refuse_schema(document["schema_version"]) + # The record as written, references and all, is what digests to its stamp. + written = document + held: dict[str, np.ndarray] = {} + document = cast("dict[str, Any]", _resolved(document, arrays or {}, held)) + + # Another build of this schema may lack a top-level entry. + stored = cast( + "Callable[..., Any]", functools.partial(_stored_entry, document, path=()) + ) + + try: + noise = stored("noise") + resolution = stored("noise_resolution") + # Absent from a record written before the defaults were recorded. + defaults = stored("defaults") + git = stored("git") + backend = stored("backend") + metadata = cls( + grid=_rebuild(GridRecord, document["grid"], ("grid",)), + modes=tuple( + _rebuild(ModeRecord, one, ("modes", index)) + for index, one in enumerate(document["modes"]) + ), + simulation_kind=document["simulation_kind"], + configuration=document["configuration"], + stepper=StepperRecord.from_record(document["stepper"]), + noise=None + if noise is None + else _rebuild(NoiseRecord, noise, ("noise",)), + backend=None if backend is None else _rebuild(BackendRecord, backend), + versions=cast("Mapping[str, str]", stored("versions", default={})), + git=None if git is None else _rebuild(GitProvenance, git), + created=str(stored("created", default="")), + schema_version=document["schema_version"], + spectral_convention=str( + stored("spectral_convention", default=SPECTRAL_CONVENTION) + ), + field_layout=str(stored("field_layout", default=FIELD_LAYOUT)), + spatial=stored("spatial"), + modal=stored("modal"), + materials=MaterialManifest.from_dict(stored("materials")), + initial_z=stored("initial_z", default=0.0), + tdse=stored("tdse"), + quadrature=stored("quadrature"), + reference=stored("reference"), + transverse=stored("transverse"), + noise_resolution=None + if resolution is None + else _rebuild(NoiseResolution, resolution), + defaults=None + if defaults is None + else _rebuild(DefaultsResolution, defaults), + ) + except (KeyError, TypeError, MaterialError) as error: + msg = f"stored metadata is missing or malformed: {error}" + raise OutputError(msg) from error + # The record as written: it digests to its own stamp, whatever today's code + # would write. + object.__setattr__(metadata, "_stored", _stored_hashed(written)) + object.__setattr__(metadata, "_stored_arrays", held) + return metadata + + +def _example_metadata() -> RunMetadata: + """Return a minimal record, so the examples above stay one line each. + + A documentation and experimentation helper, not part of the public surface: build a + real record with the :class:`RunMetadata` constructor, whose own example shows how. + """ + grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + return RunMetadata( + grid=GridRecord.from_grid(grid), + modes=(), + simulation_kind="mode_averaged", + configuration={"gamma": 0.11}, + stepper=StepperRecord(tableau="DOPRI5", controller={"rtol": 1e-8, "atol": 0.0}), + ) + + +def resolve_metadata_write( + existing: RunMetadata | None, new: RunMetadata, *, force: bool = False +) -> bool: + """Decide whether a handler may write metadata, under one rule for both handlers. + + Metadata is written once. A second write describing the same run is a no-op; one + describing a different run is a mistake and raises, naming the fields that changed, + unless the caller insists with ``force``, in which case the record is replaced and + the replacement is logged. + + "The same run" means the same :meth:`RunMetadata.configuration_hash`, so a rerun of + identical physics under a newer NumPy, or from a checkout that has since gained a + commit, is still the same run. + + Parameters + ---------- + existing : RunMetadata or None + What the handler already holds, or ``None`` when it holds nothing. + new : RunMetadata + What the caller is offering. + force : bool, optional + Whether to overwrite a different record instead of refusing. + + Returns + ------- + bool + Whether the caller should write ``new``. + + Raises + ------ + OutputError + If a different record is already held and ``force`` is false. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata, resolve_metadata_write + >>> metadata = _example_metadata() + >>> resolve_metadata_write(None, metadata) + True + >>> resolve_metadata_write(metadata, metadata) + False + """ + if existing is None: + return True + if existing.configuration_hash() == new.configuration_hash(): + return False + changed = existing.summarise_differences(new) + if not force: + msg = ( + f"metadata has already been written for a different run; {changed}. " + "Pass force=True to overwrite it." + ) + raise OutputError(msg) + logger.warning("overwriting run metadata; %s", changed) + return True diff --git a/src/solprop/output/provenance.py b/src/solprop/output/provenance.py new file mode 100644 index 00000000..e056c98c --- /dev/null +++ b/src/solprop/output/provenance.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""What executed a run and which code it was: the backend, the git commit, the versions. + +:class:`BackendRecord` is what the backend reported about itself, :class:`GitProvenance` +the commit of a development checkout with its dirty flag, and :func:`package_versions` +the installed versions. All three are outside the resume key +(:meth:`solprop.output.metadata.RunMetadata.hashed`): they say how a run was executed, +not what was computed. **Provenance, not source:** the commit and the versions are +recorded, never the source text. + +The manual page is ``docs/guide/results/provenance.md``. +""" + +import dataclasses +import functools +import platform +import subprocess +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _package_version +from pathlib import Path +from typing import cast + +from solprop.exceptions import OutputError +from solprop.modes.material import JsonValue + +__all__ = [ + "BackendRecord", + "GitProvenance", + "package_versions", +] + +# The subprocess calls that read the git provenance are bounded: a hung git must not +# stall a run that is otherwise ready to start. +_GIT_TIMEOUT = 5.0 + + +def _run_git(arguments: Sequence[str], root: Path) -> str | None: + """Return the output of one read-only git command, or ``None`` if it cannot run. + + - Returns: the stripped standard output, or ``None`` when git is absent, the + directory is not a repository, or the command fails or times out. + """ + try: + finished = subprocess.run( + ["git", *arguments], + cwd=root, + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + check=False, + ) + except OSError, subprocess.SubprocessError: + return None + if finished.returncode != 0: + return None + return finished.stdout.strip() + + +@dataclass(frozen=True) +class BackendRecord: + """What executed a run, as the backend's own ``metadata()`` reported it. + + A saved run has to say what produced it and not only what was computed: the thread + configuration alone moved one step by a factor of nine + (``docs/dev/backends.md`` section 3.4), and without this record a timing + comparison has to read the realised kernel pool out of the backend itself. + + It is **outside the resume key**, like the package versions and the git provenance: + a run continued on the other backend agrees with an uninterrupted one to + 2.2-2.7e-16 and is the same run. What it records is how, + not what. + + A resumed file keeps the record it was opened with, creation time and all, so the + backend it names is the one that **started** the run. That is append mode's rule + for the whole record and not a special case for this group. The name may be one this + package no longer has, such as ``numba_cpu``: a recorded name is not a request, so + an old file's record reads as it was written, in its backend's own vocabulary. + + Attributes + ---------- + name : str + The registry name of the backend, for example ``"numpy_ref"`` or ``"native"``. + propagator_mode : str + Which the backend resolved the contract's ``"auto"`` to, or which was asked + for: ``"tabulate"`` or ``"recompute"``. + settings : dict + Everything else the backend reported, for example its thread counts, its plan + flag and its wisdom digest. Free-form because + it is a backend's own vocabulary, and JSON-serialisable because it is written + to the file. + + Examples + -------- + >>> from solprop.output.provenance import BackendRecord + >>> record = BackendRecord.from_metadata( + ... {"backend": "native", "propagator_mode": "recompute", + ... "thread_count": 4} + ... ) + >>> record.name, record.propagator_mode, record.settings + ('native', 'recompute', {'thread_count': 4}) + """ + + name: str + propagator_mode: str + settings: Mapping[str, JsonValue] = dataclasses.field(default_factory=dict) + + def __post_init__(self) -> None: + """Adopt a plain copy of the free-form settings.""" + object.__setattr__(self, "settings", dict(self.settings)) + + @classmethod + def from_metadata(cls, metadata: Mapping[str, object]) -> "BackendRecord": + """Return the record a backend's ``metadata()`` describes. + + Parameters + ---------- + metadata : collections.abc.Mapping + What :meth:`solprop.backends.protocol.Backend.metadata` returned. + ``backend`` and ``propagator_mode`` are named fields here; everything else + becomes `settings`. + + Returns + ------- + BackendRecord + The record. + + Raises + ------ + OutputError + If either named field is missing. + """ + try: + name = str(metadata["backend"]) + mode = str(metadata["propagator_mode"]) + except KeyError as error: + msg = ( + "a backend's metadata must name the backend and the propagator mode; " + f"this one is missing {error}" + ) + raise OutputError(msg) from error + rest = { + key: cast("JsonValue", value) + for key, value in metadata.items() + if key not in {"backend", "propagator_mode"} + } + return cls(name=name, propagator_mode=mode, settings=rest) + + +@dataclass(frozen=True) +class GitProvenance: + """Which commit of solprop produced a file, and whether it was modified. + + Attributes + ---------- + commit : str + Full 40-character hexadecimal commit hash of the running checkout. + branch : str + Branch name, or ``'HEAD'`` when the checkout is detached. + dirty : bool + Whether the working tree had uncommitted changes to tracked files. A dirty + run is reproducible only up to those changes, which is the point of recording + it. It is a separate field rather than a suffix on the hash string, so the + commit stays parsable. + + Examples + -------- + >>> from solprop.output.provenance import GitProvenance + >>> record = GitProvenance(commit="0" * 40, branch="main", dirty=False) + >>> len(record.commit), record.dirty + (40, False) + """ + + commit: str + branch: str + dirty: bool + + @classmethod + def detect(cls) -> "GitProvenance | None": + """Return the provenance of the running solprop checkout, if there is one. + + An installed release is not a checkout, git may be absent, and the package may + sit inside an unrelated repository's worktree -- a virtual environment inside a + user's own project, whose ``git rev-parse`` answers with *that* project's + commit; every one of those returns ``None`` rather than a placeholder string, + so that a caller can tell "no provenance" from "this commit". The test that + separates the last case from a development checkout is whether the repository + found actually tracks this file. + + Returns + ------- + GitProvenance or None + The commit, branch and dirty flag, or ``None`` when they cannot be read. + + Examples + -------- + >>> from solprop.output.provenance import GitProvenance + >>> record = GitProvenance.detect() + >>> record is None or len(record.commit) == 40 + True + """ + here = Path(__file__).resolve() + root = here.parent + # A repository was found, but is it solprop's? Inside a virtual environment that + # lives in some other project's checkout, git answers for that project. Only a + # repository that tracks this very file is the one to record; that cannot + # change while the process runs, so it is asked once. + if not _tracks(here): + return None + # One command gives the commit, the branch and the dirty flag: the per-run + # cost of provenance is a single git process (SEP 25 section 6.3). + status = _run_git( + ["status", "--porcelain=v2", "--branch", "--untracked-files=no"], root + ) + if status is None: + return None + headers = dict( + line[2:].split(" ", 1) + for line in status.splitlines() + if line.startswith("# ") and " " in line[2:] + ) + commit = headers.get("branch.oid", "") + if len(commit) != 40: + return None + branch = headers.get("branch.head", "") + dirty = any(line and not line.startswith("#") for line in status.splitlines()) + return cls( + commit=commit, + branch="HEAD" if branch in {"", "(detached)"} else branch, + dirty=dirty, + ) + + +@functools.cache +def _tracks(path: Path) -> bool: + """Return whether the git repository around `path` tracks it, asked once. + + - Returns: ``True`` when git runs, finds a repository and that repository tracks the + file. + """ + return _run_git(["ls-files", "--error-unmatch", path.name], path.parent) is not None + + +def _version_of(name: str) -> str: + """Return an installed distribution's version, or ``'unknown'``. + + - Returns: the version string, or ``'unknown'`` when the distribution is not + installed. + """ + try: + return _package_version(name) + except PackageNotFoundError: # pragma: no cover - every dependency is declared + return "unknown" + + +def package_versions() -> dict[str, str]: + """Return the versions of solprop and of the packages a stored file depends on. + + Recorded so that a file can be read years later with the same numerics in view. The + versions are deliberately *outside* the configuration hash: upgrading NumPy must not + refuse to resume a run. optmat's version is here too, for a reader; a run that takes + a material from optmat carries it again in its material manifest + (:attr:`~solprop.output.metadata.RunMetadata.materials`), where it is part of the + key. + + Returns + ------- + dict + Version strings keyed by ``'solprop'``, ``'numpy'``, ``'scipy'``, ``'h5py'``, + ``'optmat'`` (its full version string) and ``'python'``. + + Examples + -------- + >>> from solprop.output.provenance import package_versions + >>> sorted(package_versions()) + ['h5py', 'numpy', 'optmat', 'python', 'scipy', 'solprop'] + """ + return dict(_installed_versions()) + + +@functools.cache +def _installed_versions() -> dict[str, str]: + """Return :func:`package_versions`, read once per process. + + Every record asks, so every scan point did; what is installed does not change + under a running process. + + - Returns: the versions; callers copy it. + """ + return { + "solprop": _version_of("solprop"), + "numpy": _version_of("numpy"), + "scipy": _version_of("scipy"), + "h5py": _version_of("h5py"), + "optmat": _version_of("optmat"), + "python": platform.python_version(), + } diff --git a/src/solprop/output/records.py b/src/solprop/output/records.py new file mode 100644 index 00000000..53cc02a8 --- /dev/null +++ b/src/solprop/output/records.py @@ -0,0 +1,1524 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The record types of a saved run: its grid, modes, noise, stepper and saved planes. + +Each is a frozen dataclass that a handler writes and reads back. :class:`SavedPlane` +carries one saved position with its field, its statistics and the step history since the +previous plane; :class:`RunOutcome` and :class:`TerminalCheckpoint` how a run ended. + +**Conventions on the way out.** A ``SavedPlane`` holds its field in the internal FFT +convention, as a backend produces it; a handler converts it with +:func:`solprop.fourier.spectrum_to_optics` on the way to storage, so that what is stored +is the window-centred optics spectrum a spectrometer would agree with, marked +``spectral_convention = "optics"`` (:data:`SPECTRAL_CONVENTION`; +``docs/dev/conventions.md`` §2). The noise realisation and ``noise_phase`` are internal +objects and stay in the FFT convention, marked so by :data:`INTERNAL_CONVENTION`. + +The manual page is ``docs/guide/results/output.md``. +""" + +import copy +import dataclasses +import functools +import hashlib +import json +import math +import weakref +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Literal, cast + +import numpy as np +from jaxtyping import Bool, Complex, Float, Int + +from solprop import fourier +from solprop._display import Displayed, engineering, line, summarise +from solprop.exceptions import OutputError +from solprop.grid import TimeGrid +from solprop.modes.material import JsonValue +from solprop.modes.protocol import Mode +from solprop.noise import NoiseRealisation +from solprop.output.serialisation import ( + _decode, + _optional_text, + _rebuild, + _stored_entry, + serialise, +) +from solprop.stepper import FixedSchedule, PIController, Tableau + +__all__ = [ + "FIELD_LAYOUT", + "HISTORY_FIELDS", + "INTERNAL_CONVENTION", + "SPECTRAL_CONVENTION", + "STEP_STATS_INDEX", + "Convention", + "DefaultsResolution", + "GridRecord", + "MarchSnapshot", + "ModeRecord", + "NoiseRecord", + "NoiseResolution", + "RecipeFile", + "RecipeRecord", + "RunOutcome", + "SavedPlane", + "SimulationKind", + "StepHistory", + "StepLimits", + "StepStats", + "StepperRecord", + "StoredField", + "TerminalCheckpoint", +] + +SPECTRAL_CONVENTION = "optics" +"""Sign convention of every spectrum a handler stores.""" + +INTERNAL_CONVENTION = "fft" +"""Sign convention of the arrays that stay internal: the noise field and its phase.""" + +FIELD_LAYOUT = "(n_z, n_modes, n_pol, n_omega)" +"""Axis order of the stored field, as ``docs/dev/conventions.md`` §6 fixes it.""" + +SimulationKind = Literal["mode_averaged", "multimode", "radial", "free", "slab"] +"""Which transform the run used.""" + +Convention = Literal["optics", "fft"] +"""Which sign convention an accessor should hand back.""" + + +@dataclass(frozen=True, kw_only=True) +class GridRecord: + """The scalars that identify the grid a run was computed on. + + Only scalars: the four axis arrays a :class:`solprop.grid.TimeGrid` holds are + reconstructible from these, and keeping them out of the record keeps the metadata + JSON, and the configuration hash taken over it, small. + + Attributes + ---------- + kind : {'field', 'envelope'} + Spectral layout, which fixes what the stored field means and which conversion + the storage boundary applies. + n_t : int + Coarse time samples. + n_to : int + Fine time samples. + n_omega : int + Coarse spectral bins, the length of the stored field's last axis. + n_omega_fine : int + Fine spectral bins. + dt : float + Coarse sample spacing, s. + dt_fine : float + Fine sample spacing, s. + ffac : float + Oversampling factor, dimensionless. + omega0 : float + Reference angular frequency, rad/s. + d_omega : float + Spectral bin spacing, rad/s. + length : float + Propagation length the grid was built for, m. + reference_wavelength : float + Wavelength of ``omega0``, m. + omega_min : float + Lower in-band angular frequency, rad/s. + omega_max : float + Upper in-band angular frequency, rad/s. + omega_max_window : float + Upper edge of the active band, rad/s. + t_window_min : float + Lower inner edge of the temporal collar, s: the window is flat between this + and ``t_window_max`` and tapers to the ends of the axis outside them. + t_window_max : float + Upper inner edge of the temporal collar, s. + + Examples + -------- + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.output.records import GridRecord + >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + >>> record = GridRecord.from_grid(grid) + >>> record.kind, record.n_omega == grid.n_omega + ('envelope', True) + """ + + kind: fourier.GridKind + n_t: int + n_to: int + n_omega: int + n_omega_fine: int + dt: float + dt_fine: float + ffac: float + omega0: float + d_omega: float + length: float + reference_wavelength: float + omega_min: float + omega_max: float + omega_max_window: float + t_window_min: float + t_window_max: float + + @classmethod + def from_grid(cls, grid: TimeGrid) -> "GridRecord": + """Return the record of a grid. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + The grid the run used. + + Returns + ------- + GridRecord + Its scalar description. + + Examples + -------- + >>> from solprop.grid import FieldGrid + >>> from solprop.output.records import GridRecord + >>> grid = FieldGrid.from_limits(1.0, 800e-9, (400e-9, 2e-6), 1e-12) + >>> GridRecord.from_grid(grid).kind + 'field' + """ + return cls( + kind=grid.kind, + n_t=grid.n_t, + n_to=grid.n_to, + n_omega=grid.n_omega, + n_omega_fine=grid.n_omega_fine, + dt=grid.dt, + dt_fine=grid.dt_fine, + ffac=grid.ffac, + omega0=grid.omega0, + d_omega=grid.d_omega, + length=grid.length, + reference_wavelength=grid.reference_wavelength, + omega_min=grid.omega_min, + omega_max=grid.omega_max, + omega_max_window=grid.omega_max_window, + t_window_min=grid.t_collar.one_low, + t_window_max=grid.t_collar.one_high, + ) + + +@dataclass(frozen=True, kw_only=True) +class ModeRecord: + """One propagated mode, by class, effective area, channel label and configuration. + + Attributes + ---------- + kind : str + Qualified class name of the mode, ``'module.ClassName'``. + effective_area : float + ``A_eff`` in m^2 at the reference position, the one number processing needs + most often and the one a plot label carries. + configuration : dict + The mode object as :func:`~solprop.output.serialisation.serialise` records it. + label : collections.abc.Mapping or None + The physical channel this row of the mode axis is, as + :meth:`solprop.modes.protocol.ChannelLabel.as_mapping` writes it: the + family, the azimuthal and radial orders, the parity, the handedness and + the display label. :meth:`from_mode` always records one; ``None`` on a + record built without it, which names its channel only by class name and + position, and which :func:`solprop.processing.modal.channel_labels` + refuses. Part of :meth:`~solprop.output.metadata.RunMetadata.hashed`. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.output.records import ModeRecord + >>> record = ModeRecord.from_mode(MarcatiliMode(125e-6, "He", 1.0)) + >>> record.kind + 'solprop.modes.capillary.MarcatiliMode' + >>> None if record.label is None else record.label["display_label"] + 'HE1,1:even' + """ + + kind: str + effective_area: float + configuration: Mapping[str, JsonValue] + label: Mapping[str, JsonValue] | None = None + + @classmethod + def from_mode(cls, mode: Mode, z: float = 0.0) -> "ModeRecord": + """Return the record of a mode. + + Parameters + ---------- + mode : solprop.modes.protocol.Mode + The mode. + z : float, optional + Position along the waveguide the effective area is read at, m. + + Returns + ------- + ModeRecord + Its description. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.output.records import ModeRecord + >>> record = ModeRecord.from_mode(MarcatiliMode(75e-6, "He", 1.0)) + >>> f"{record.effective_area:.6e}" + '8.421575e-09' + """ + kind = type(mode) + # serialise returns a dictionary for everything that is not a primitive, a + # sequence or a callable, and a mode is none of those, so the cast is total. + configuration = cast("dict[str, JsonValue]", serialise(mode)) + if getattr(mode, "mixture", None) is not None: + # These legacy constructor slots are placeholders when a mixture owns + # every species model and density path; they would imply a second fill. + for name in ("gas", "pressure", "temperature", "source", "susceptibility"): + configuration.pop(name, None) + return cls( + kind=f"{kind.__module__}.{kind.__qualname__}", + effective_area=mode.aeff(z), + configuration=configuration, + label=dict(mode.channel_label.as_mapping()), + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class NoiseRecord: + """The shot-noise realisation a run started from, and the seed that drew it. + + Equality is by identity (``eq=False``) because ``spectrum`` is an array; compare + the seeds, which is what reproducibility means. + + The spectrum is stored in the **internal** FFT convention, unlike the field: it is + not a user-facing spectrum but the object the stepper adds at every stage, and + conjugating it would make it disagree with the file it has to be read back into. + :attr:`spectral_convention` says so explicitly. + + Attributes + ---------- + model : {'input', 'modified'} + Which noise model the realisation belongs to. + seed : int or None + Entropy of the generator, or ``None`` when it could not be recovered. + spectrum : numpy.ndarray + The realisation, shape ``(n_modes, n_pol, n_omega)``, complex, in the square + root of watts. + spectral_convention : str + Always ``'fft'``. + + Examples + -------- + >>> import numpy as np + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.noise import input_noise + >>> from solprop.output.records import NoiseRecord + >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + >>> realisation = input_noise(grid, np.random.default_rng(3)) + >>> record = NoiseRecord.from_realisation(realisation) + >>> record.seed, record.spectral_convention + (3, 'fft') + """ + + model: str + seed: int | None + spectrum: Complex[np.ndarray, "n_modes n_pol n_omega"] + spectral_convention: str = INTERNAL_CONVENTION + + def __post_init__(self) -> None: + """Adopt a read-only copy of the spectrum, so a record cannot drift.""" + frozen = np.array(self.spectrum, dtype=np.complex128, copy=True) + frozen.setflags(write=False) + object.__setattr__(self, "spectrum", frozen) + + @classmethod + def from_realisation(cls, realisation: NoiseRealisation) -> "NoiseRecord": + """Return the record of a drawn realisation. + + Parameters + ---------- + realisation : solprop.noise.NoiseRealisation + The realisation the run used. + + Returns + ------- + NoiseRecord + Its description. + + Examples + -------- + >>> import numpy as np + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.noise import modified_noise + >>> from solprop.output.records import NoiseRecord + >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + >>> NoiseRecord.from_realisation(modified_noise(grid, np.random.default_rng(1))) + ... # doctest: +ELLIPSIS + NoiseRecord(model='modified', seed=1, ...) + """ + return cls( + model=realisation.model, + seed=realisation.seed, + spectrum=realisation.spectrum, + ) + + +@dataclass(frozen=True) +class NoiseResolution: + """How a run's noise model was chosen: what was asked for, what it carries, and why. + + Attributes + ---------- + requested : {'default', 'input', 'modified'} or None + The model the caller asked for: ``"default"`` when the keyword was omitted, + ``None`` when noise was switched off explicitly. + resolved : {'input', 'modified'} or None + The model the run carries, or ``None`` for a noiseless run. + reason : str or None + Why a default resolved to no noise, for example that the hybrid planar grid + declares no photon measure; ``None`` otherwise. + + Examples + -------- + >>> from solprop.output.records import NoiseResolution + >>> NoiseResolution("default", "input") + NoiseResolution(requested='default', resolved='input', reason=None) + """ + + requested: str | None + resolved: str | None + reason: str | None = None + + +@dataclass(frozen=True) +class DefaultsResolution: + """Which of a run's numerics took its route's default, and which route that was. + + The companion of :class:`NoiseResolution` for the stepping (``docs/guide/numerics/ + choosing-a-stepper.md``): the integrator, the step controller and the temporal + collar's scheme are each either the caller's or the route's default from + :data:`solprop.driver.defaults.ROUTE_DEFAULTS`. Outside the hash: what the run + computes is in the stepper record already, and how it was chosen is not physics. + + Attributes + ---------- + route : str or None + The row of the defaults table the constructor read, or ``None`` for settings + built by hand. + defaulted : sequence of str + Which of ``"integrator"``, ``"rtol"`` and ``"absorber_scheme"`` took the + route's default, in that order, adopted as a tuple. + + Examples + -------- + >>> from solprop.output.records import DefaultsResolution + >>> DefaultsResolution("capillary", ["integrator", "rtol"]) + DefaultsResolution(route='capillary', defaulted=('integrator', 'rtol')) + """ + + route: str | None + defaulted: Sequence[str] = () + + def __post_init__(self) -> None: + """Adopt the names as a tuple, so a record read back compares equal.""" + object.__setattr__(self, "defaulted", tuple(self.defaulted)) + + +@dataclass(frozen=True) +class StepLimits: + """The caps a run put on the controller's step proposal. + + Grouped into one record because they travel together and because a constructor + that took them one by one would need more arguments than the project allows. + + Attributes + ---------- + dz_min : float + Smallest step the controller was allowed to propose, m. Zero when the driver + left it to :func:`solprop.stepper.controller.default_dz_min`. + dz_max : float + Largest step, m; infinite when uncapped. + dz_init : float + First step, m; zero when the driver chose it. + stretch : float + Landing stretch factor, dimensionless; 1 switches landing stretch off. + interval_cap : bool + Whether a step was capped at its operator-table interval's width. + auto_init : bool + Whether the start-up rule took the step after the first from the first step's + estimate. + interpolate_planes : bool + Whether the save planes but the end and the hard kinks were interpolated + inside accepted steps; ``False`` when every plane was landed on. + step_planes : int + A positive ``k`` when the end of every ``k``-th accepted step was saved too; + ``0`` for the requested planes only. + + Examples + -------- + >>> from solprop.output.records import StepLimits + >>> StepLimits(dz_max=0.01).dz_max + 0.01 + """ + + dz_min: float = 0.0 + dz_max: float = math.inf + dz_init: float = 0.0 + stretch: float = 1.1 + interval_cap: bool = True + auto_init: bool = False + interpolate_planes: bool = False + step_planes: int = 0 + + +@dataclass(frozen=True, eq=False, kw_only=True) +class StepperRecord: + """The solver settings a run used, including the schedule it actually took. + + Equality is by identity (``eq=False``) because ``schedule`` is an array. Schedules + are first class and re-loadable (see ``docs/dev/decisions.md``): a replayed run is + bit-identical, and every ensemble member then carries the same numerical error as a + common systematic. + + Build one from the objects a run held with :meth:`from_settings`, and from a stored + document with :meth:`from_record`. + + Attributes + ---------- + tableau : str + Name of the Runge-Kutta scheme. + method : str or None + The integrator that stepped the run instead of the tableau, as its ``repr``: + a low-call Adams method (SEP 13) or the low-storage march (SEP 24); ``None`` + for the Runge-Kutta march. + controller : dict + The step controller as :func:`~solprop.output.serialisation.serialise` records + it: tolerances, safety and gain factors, lag and the norm weights. + limits : StepLimits + The caps on the controller's proposal. + schedule : numpy.ndarray or None + Step lengths of shape ``(n_steps,)``, m, when a fixed schedule was replayed or + recorded; ``None`` for a purely adaptive run. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import StepLimits, StepperRecord + >>> from solprop.stepper import DOPRI5, FixedSchedule, PIController + >>> record = StepperRecord.from_settings( + ... DOPRI5, + ... PIController(rtol=1e-8, atol=0.0), + ... schedule=FixedSchedule(np.array([0.5, 0.5])), + ... limits=StepLimits(dz_max=0.1), + ... ) + >>> record.tableau, record.limits.dz_max + ('DOPRI5', 0.1) + """ + + tableau: str + controller: Mapping[str, JsonValue] + limits: StepLimits = dataclasses.field(default_factory=StepLimits) + schedule: Float[np.ndarray, " n_steps"] | None = None + method: str | None = None + + def __post_init__(self) -> None: + """Adopt a read-only copy of the schedule, so a record cannot drift.""" + if self.schedule is not None: + frozen = np.array(self.schedule, dtype=np.float64, copy=True) + frozen.setflags(write=False) + object.__setattr__(self, "schedule", frozen) + + @classmethod + def from_settings( + cls, + tableau: Tableau, + controller: PIController, + *, + schedule: FixedSchedule | None = None, + limits: StepLimits | None = None, + method: str | None = None, + ) -> "StepperRecord": + """Return the record of the objects a run stepped with. + + Parameters + ---------- + tableau : solprop.stepper.tableau.Tableau + The scheme. + controller : solprop.stepper.controller.PIController + The step controller, which carries the tolerances and the norm choice. + schedule : solprop.stepper.schedule.FixedSchedule, optional + The fixed schedule replayed, if any. + limits : StepLimits, optional + The caps on the controller's proposal; the defaults when omitted. + method : str or None, optional + The Adams method that stepped the run instead of the tableau, as its + ``repr``; ``None`` for the Runge-Kutta march. + + Returns + ------- + StepperRecord + The record. + + Examples + -------- + >>> from solprop.output.records import StepperRecord + >>> from solprop.stepper import TSIT5, PIController + >>> record = StepperRecord.from_settings( + ... TSIT5, PIController(rtol=1e-6, atol=0.0) + ... ) + >>> record.tableau, record.schedule is None + ('Tsit5', True) + """ + # A PIController is a dataclass, so serialise always returns a dictionary. + record = cast("dict[str, JsonValue]", serialise(controller)) + return cls( + tableau=tableau.name, + controller=record, + limits=StepLimits() if limits is None else limits, + schedule=None if schedule is None else schedule.steps, + method=method, + ) + + @classmethod + def from_record(cls, document: Mapping[str, JsonValue]) -> "StepperRecord": + """Return the record a stored document describes. + + Parameters + ---------- + document : collections.abc.Mapping + The form :func:`~solprop.output.serialisation.serialise` produced from a + record. + + Returns + ------- + StepperRecord + The record. + + Examples + -------- + >>> from solprop.output.records import StepperRecord + >>> from solprop.output.serialisation import serialise + >>> from solprop.stepper import TSIT5, PIController + >>> one = StepperRecord.from_settings(TSIT5, PIController(rtol=1e-6, atol=0.0)) + >>> StepperRecord.from_record(serialise(one)).tableau + 'Tsit5' + """ + # Another build of this schema may lack an optional field. + stored = cast( + "Callable[..., Any]", + functools.partial(_stored_entry, document, path=("stepper",)), + ) + + schedule = _decode(stored("schedule")) + limits = stored("limits") + return cls( + tableau=str(stored("tableau", required=True)), + controller=cast( + "dict[str, JsonValue]", stored("controller", required=True) + ), + limits=StepLimits() + if limits is None + else _rebuild( + StepLimits, cast("dict[str, JsonValue]", limits), ("stepper", "limits") + ), + schedule=None if schedule is None else np.asarray(schedule, dtype=float), + method=_optional_text(stored("method")), + ) + + +HISTORY_FIELDS: Mapping[str, type] = { + "z": np.float64, + "h": np.float64, + "norm": np.float64, + "accepted": np.bool_, + "wall_time": np.float64, +} +"""Dtype of each :class:`StepHistory` field, in the order they are laid out.""" + + +@dataclass(frozen=True, eq=False, kw_only=True) +class StepHistory(Displayed): + """What the stepper did between two saved planes. + + Recorded whether the run was adaptive or replayed a schedule, because the embedded + error estimate costs nothing extra and a fixed run still needs it: a largest scaled + norm above one means a step was too coarse for the tolerance the norm is scaled by + (``docs/dev/stepper.md`` §8). + + Equality is by identity (``eq=False``) because every field is an array. + + Attributes + ---------- + z : numpy.ndarray + Position at the start of each attempt, shape ``(n_steps,)``, m. + h : numpy.ndarray + Length of each attempt, shape ``(n_steps,)``, m. + norm : numpy.ndarray + Scaled error norm each attempt scored as executed, shape ``(n_steps,)``, + dimensionless; the controller's rescaled equivalent of a landing step is its + own and is not recorded. The low-storage march estimates none and records 0.0, + "not estimated", never a step that met its tolerance exactly. + accepted : numpy.ndarray + Whether each attempt was accepted, shape ``(n_steps,)``, boolean. + wall_time : numpy.ndarray + Wall-clock time each attempt took, shape ``(n_steps,)``, s. + + Raises + ------ + OutputError + If the five arrays do not have equal shapes. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import StepHistory + >>> history = StepHistory( + ... z=np.array([0.0, 0.5]), + ... h=np.array([0.5, 0.5]), + ... norm=np.array([0.3, 0.4]), + ... accepted=np.array([True, True]), + ... wall_time=np.array([1e-3, 1e-3]), + ... ) + >>> history.n_steps, history.n_rejected + (2, 0) + """ + + # Independent axis names on purpose: their agreement is checked below with an + # OutputError naming the culprit, and that check must also hold in production, + # where the test session's runtime type checker is absent. One shared name would + # let the checker forestall it. + z: Float[np.ndarray, " n_history_z"] + h: Float[np.ndarray, " n_history_h"] + norm: Float[np.ndarray, " n_history_norm"] + accepted: Bool[np.ndarray, " n_history_accepted"] + wall_time: Float[np.ndarray, " n_history_wall"] + + def __repr__(self) -> str: + """Return a one-line description: attempts, rejections and wall time. + + Returns + ------- + str + For example ``StepHistory(1842 attempts, 12 rejected, 12.2 s)``. + """ + attempts = int(np.asarray(self.z).size) + rejected = attempts - int(np.count_nonzero(self.accepted)) + seconds = float(np.sum(self.wall_time)) if attempts else 0.0 + return line( + "StepHistory", + [ + ("", f"{attempts} attempts"), + ("", f"{rejected} rejected"), + ("", engineering(seconds, "s")), + ], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + attempts = int(np.asarray(self.z).size) + if not attempts: + return "StepHistory: no step attempted", [] + accepted = np.asarray(self.accepted, dtype=bool) + steps = np.asarray(self.h, dtype=float)[accepted] + return "StepHistory", [ + ( + "attempts", + f"{attempts}, of which {attempts - int(accepted.sum())} rejected", + ), + ("accepted steps", summarise(steps, "m").replace("values", "steps")), + ("largest error norm", f"{float(np.max(self.norm)):.3g}"), + ("wall time", engineering(float(np.sum(self.wall_time)), "s")), + ] + + def __post_init__(self) -> None: + """Check that the five arrays describe one sequence, and freeze them. + + Raises + ------ + OutputError + If the five shapes disagree. That they are one-dimensional is fixed by the + annotations, as it is for :class:`solprop.stepper.tableau.Tableau`. + """ + shapes = {} + for name, dtype in HISTORY_FIELDS.items(): + values = np.array(getattr(self, name), dtype=dtype, copy=True) + values.setflags(write=False) + object.__setattr__(self, name, values) + shapes[name] = values.shape + if len(set(shapes.values())) > 1: + msg = f"step history arrays must have equal shapes, got {shapes}" + raise OutputError(msg) + + @classmethod + def empty(cls) -> "StepHistory": + """Return the history of a plane no step led to, such as the starting plane. + + Returns + ------- + StepHistory + Five empty arrays. + + Examples + -------- + >>> from solprop.output.records import StepHistory + >>> StepHistory.empty().n_steps + 0 + """ + return cls( + z=np.empty(0), + h=np.empty(0), + norm=np.empty(0), + accepted=np.empty(0, dtype=bool), + wall_time=np.empty(0), + ) + + @property + def n_steps(self) -> int: # numpydoc ignore=RT01 + """Number of attempts recorded. + + Accepted and rejected attempts together. + """ + return int(self.z.size) + + @property + def n_rejected(self) -> int: # numpydoc ignore=RT01 + """Number of rejected attempts.""" + return int(np.count_nonzero(~self.accepted)) + + +STEP_STATS_INDEX: Mapping[str, type] = {"z": np.float64, "step": np.int64} +"""Dtype of each :class:`StepStats` index column, beside the statistics themselves.""" + + +@dataclass(frozen=True, eq=False, kw_only=True) +class StepStats(Displayed): + """The statistics a backend evaluated *between* two saved planes. + + A run whose :class:`solprop.stats.StatsSpec` names a ``periodic`` subset gets one + row every ``period`` accepted steps, evaluated inside the backend's step loop and + flushed here at each plane. Empty for a run that asked for none, which is + the default. + + Stored the way the step history is -- flat columns concatenated over planes with a + per-plane ``counts`` beside them -- so that a resume appends and a scan needs no NaN + padding, which growing datasets would force on it. + + Equality is by identity (``eq=False``) because every field is an array. + + Attributes + ---------- + z : numpy.ndarray + Position of each row, m, float64, shape ``(n_rows,)``. + step : numpy.ndarray + Index of the accepted step each row was taken after, into this plane's + :class:`StepHistory`, int64, shape ``(n_rows,)``. The history counts rejected + attempts, so this is not ``row * period``. + values : dict + The statistics, keyed by catalogue name, each shaped ``(n_rows, *shape)`` with + the statistic's own shape after the row axis, as the backend's rows are. + + Raises + ------ + OutputError + If the columns do not all have the same number of rows. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import StepStats + >>> rows = StepStats(z=np.array([0.1, 0.2]), step=np.array([0, 2]), + ... values={"energy": np.ones((2, 1, 1))}) + >>> rows.n_rows, rows.names + (2, ('energy',)) + """ + + z: Float[np.ndarray, " n_rows_z"] + step: Int[np.ndarray, " n_rows_step"] + values: Mapping[str, np.ndarray] = dataclasses.field(default_factory=dict) + + def __repr__(self) -> str: + """Return a one-line description: how many rows of which statistics. + + Returns + ------- + str + For example ``StepStats(1842 rows of energy, peak_power)``. + """ + rows = int(np.asarray(self.z).size) + return f"StepStats({rows} rows of {', '.join(self.values) or 'nothing'})" + + def __post_init__(self) -> None: + """Freeze the columns and check that they describe the same rows. + + Raises + ------ + OutputError + Naming the columns whose row counts disagree. + """ + shapes = {} + for name, dtype in STEP_STATS_INDEX.items(): + values = np.array(getattr(self, name), dtype=dtype, copy=True) + values.setflags(write=False) + object.__setattr__(self, name, values) + shapes[name] = values.shape + adopted = {} + for name, value in self.values.items(): + rows = np.array(value, copy=True) + rows.setflags(write=False) + adopted[name] = rows + shapes[name] = rows.shape[:1] + object.__setattr__(self, "values", adopted) + if len(set(shapes.values())) > 1: + msg = f"step statistics columns must have equal row counts, got {shapes}" + raise OutputError(msg) + + @classmethod + def empty(cls) -> "StepStats": + """Return the channel a run without a periodic subset carries. + + Returns + ------- + StepStats + No rows and no statistics. + """ + return cls(z=np.empty(0), step=np.empty(0, dtype=np.int64), values={}) + + @property + def n_rows(self) -> int: # numpydoc ignore=RT01 + """How many rows belong to this plane.""" + return int(self.z.size) + + @property + def names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The statistics the channel carries, sorted.""" + return tuple(sorted(self.values)) + + +_ADOPTED: weakref.WeakValueDictionary[int, np.ndarray] = weakref.WeakValueDictionary() +"""The arrays :func:`_adopted` marked, by id, for as long as each lives.""" + + +def _adopted(values: np.ndarray) -> np.ndarray: + """Mark an array a :class:`SavedPlane` takes as it is, instead of copying it. + + The driver's route (:func:`solprop.driver.run.run_problem`) for what an observation + holds. Only for an array that nothing writes to again, as + :meth:`solprop.backends.protocol.HostStepping.observe` promises, and never for a + batch member, whose view would pin the whole batch. + + - Returns: the same array, read-only. + """ + values.setflags(write=False) + _ADOPTED[id(values)] = values + return values + + +def _plane_array( + values: np.ndarray, dtype: type[np.complex128] | type[np.float64] +) -> np.ndarray: + """Return an array as a :class:`SavedPlane` holds it: read-only, nobody else's. + + - Returns: `values` itself when :func:`_adopted` marked it, a frozen copy otherwise. + """ + if _ADOPTED.get(id(values)) is values: + return values + held = np.array(values, dtype=dtype, copy=True) + held.setflags(write=False) + return held + + +@dataclass(frozen=True, eq=False, kw_only=True) +class SavedPlane(Displayed): + """One saved position: the field there, its statistics and how it was reached. + + The field is in the **internal** FFT convention, exactly as a backend produces it; a + handler converts it on the way to storage. Equality is by identity (``eq=False``) + because the field is an array. A **field-free** plane, of a run that keeps no field + there (:class:`~solprop.output.store.FieldRetention`), carries ``field=None`` and no + noise phase: its position, statistics and step history only. + + Attributes + ---------- + z : float + Position along the waveguide, m. + field : numpy.ndarray or None + Stored spectrum, shape ``(n_modes, n_pol, n_omega)``, complex, in the square + root of watts, internal convention; ``None`` on a field-free plane. + stats : dict + Statistics evaluated at this plane, keyed by catalogue name; the values are the + arrays :func:`solprop.stats.collect` returns. + step_history : StepHistory + Every attempt the stepper made since the previous plane. + step_stats : StepStats + The rows a periodic statistics subset produced since the previous plane, empty + unless the run asked for one. + noise_phase : numpy.ndarray or None + Accumulated unitary phase of the noise field at this plane, shape + ``(n_modes, n_pol, n_omega)``, rad, internal convention, so that processing can + restore the background; ``None`` when no noise model is active, and on a + field-free plane. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import SavedPlane, StepHistory + >>> plane = SavedPlane( + ... z=0.0, + ... field=np.ones((1, 1, 5), dtype=complex), + ... stats={"energy": np.ones((1, 1))}, + ... step_history=StepHistory.empty(), + ... ) + >>> plane.shape + (1, 1, 5) + """ + + z: float + field: Complex[np.ndarray, "n_modes n_pol n_omega"] | None + stats: Mapping[str, np.ndarray] = dataclasses.field(default_factory=dict) + step_history: StepHistory = dataclasses.field(default_factory=StepHistory.empty) + step_stats: StepStats = dataclasses.field(default_factory=StepStats.empty) + noise_phase: Float[np.ndarray, "n_modes_phase n_pol_phase n_omega_phase"] | None = ( + None + ) + + def __repr__(self) -> str: + """Return a one-line description: the position and the field's shape. + + Returns + ------- + str + For example ``SavedPlane(z=1.5 m, <2x1x8192 complex>, 14 statistics)``. + """ + return line( + "SavedPlane", + [ + ("z", engineering(self.z, "m")), + ("", self._field_text()), + ("", f"{len(self.stats)} statistics"), + ], + ) + + def _field_text(self) -> str: + """Return the field summarised, or a note that the plane keeps none. + + - Returns: the field's shape, or ``"no field"``. + """ + return "no field" if self.field is None else summarise(self.field) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + return f"SavedPlane at z = {engineering(self.z, 'm')}", [ + ("field", self._field_text()), + ("statistics", ", ".join(self.stats) or "none"), + ("steps since last", repr(self.step_history)), + ("between planes", repr(self.step_stats)), + ] + + def __post_init__(self) -> None: + """Freeze the arrays and check that a noise phase matches the field. + + Raises + ------ + OutputError + If ``z`` is not finite, ``noise_phase`` does not have the field's shape, or + a field-free plane carries a noise phase. + """ + if not math.isfinite(self.z): + msg = f"a saved plane needs a finite position, got {self.z!r}" + raise OutputError(msg) + object.__setattr__(self, "stats", dict(self.stats)) + if self.field is None: + if self.noise_phase is not None: + msg = "a field-free plane carries no noise phase" + raise OutputError(msg) + return + field = _plane_array(self.field, np.complex128) + object.__setattr__(self, "field", field) + if self.noise_phase is not None: + phase = _plane_array(self.noise_phase, np.float64) + if phase.shape != field.shape: + msg = ( + f"noise_phase has shape {phase.shape}, expected the field's " + f"{field.shape}" + ) + raise OutputError(msg) + object.__setattr__(self, "noise_phase", phase) + + @property + def shape(self) -> tuple[int, ...] | None: # numpydoc ignore=RT01 + """The field's shape, or ``None`` on a field-free plane. + + ``(n_modes, n_pol, n_omega)``. + """ + return None if self.field is None else self.field.shape + + +@dataclass(frozen=True, eq=False) +class StoredField: + """A kept plane's field, exported straight into a plane store's stored form. + + What a run's service hands :meth:`~solprop.output.store.PlaneStore.save_exported` + beside a plane that carries no field of its own (SEP 47 section 5.4): the field as + the store keeps it, and the noise phase that a plane with its field would carry. + + Attributes + ---------- + values : numpy.ndarray + The field in the stored form, ``(n_modes, n_pol, n_omega)``: true-power + amplitudes (or a spatial route's field measure), sqrt(W), in the optics + convention, as :meth:`~solprop.output.store.PlaneStore.save` writes a plane's + field. + noise_phase : numpy.ndarray or None + The noise field's accumulated phase at the plane, rad, internal convention, the + shape of `values`; ``None`` without a noise model. + + Raises + ------ + OutputError + If `noise_phase` does not have the shape of `values`. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import StoredField + >>> StoredField(np.ones((1, 1, 4), complex)).values.shape + (1, 1, 4) + """ + + values: Complex[np.ndarray, "n_modes n_pol n_omega"] + noise_phase: Float[np.ndarray, "n_modes_phase n_pol_phase n_omega_phase"] | None = ( + None + ) + + def __post_init__(self) -> None: + """Check that a noise phase has the field's shape.""" + if self.noise_phase is not None and self.noise_phase.shape != self.values.shape: + msg = ( + f"noise_phase has shape {self.noise_phase.shape}, expected the field's " + f"{self.values.shape}" + ) + raise OutputError(msg) + + +def _with_phase(plane: SavedPlane, phase: np.ndarray) -> SavedPlane: + """Return a field-free plane carrying an exported field's noise phase. + + The store's own view of a plane whose field it holds apart, in the stored form + (:class:`StoredField`); it never leaves the store, whose hooks read the noise + phase from the plane as from one that carries its field. + """ + held = dataclasses.replace(plane) + object.__setattr__(held, "noise_phase", _plane_array(phase, np.float64)) + return held + + +@dataclass(frozen=True) +class RunOutcome: + """Record completion and cumulative attempted work separately from run identity. + + Attributes + ---------- + status : {'running', 'complete', 'budget_exhausted', 'failed'} + Current execution status. A budget stop is explicitly unfinished. + z : float + Last accepted or saved position, m. + message : str + Explanation of a stop or failure; empty on ordinary progress. + logical_calls : collections.abc.Mapping + Cumulative logical response ledger, including startup and failed attempts. + atomic_calls : collections.abc.Mapping + Cumulative provider ledger; empty when no atomic service is bound. + execution : collections.abc.Mapping + Current backend and provider facts, captured before resources close. Kept + separately from the original run metadata so resumes retain their actual + device, plan and diagnostic provenance without changing scientific identity. + previous_executions : tuple of collections.abc.Mapping + Closing records of earlier executions of the same resumed run, in order. + """ + + status: Literal["running", "complete", "budget_exhausted", "failed"] + z: float + message: str = "" + logical_calls: Mapping[str, object] = dataclasses.field(default_factory=dict) + atomic_calls: Mapping[str, object] = dataclasses.field(default_factory=dict) + execution: Mapping[str, object] = dataclasses.field(default_factory=dict) + previous_executions: tuple[Mapping[str, object], ...] = () + + def __post_init__(self) -> None: + """Reject ambiguous status and nonfinite positions, and adopt ledger copies.""" + if self.status not in ("running", "complete", "budget_exhausted", "failed"): + raise OutputError(f"unknown run outcome {self.status!r}") + if not np.isfinite(self.z) or self.z < 0: + raise OutputError("run outcome z must be finite and nonnegative") + # One JSON round trip validates and copies every part: the record is built + # at every saved plane, and one per part and per amended copy, nine a plane, + # were about 5 % of a short capillary run on MemoryOutput. + parts: dict[str, object] = { + "logical_calls": dict(self.logical_calls), + "atomic_calls": dict(self.atomic_calls), + "execution": dict(self.execution), + "previous_executions": [ + dict(record) for record in self.previous_executions + ], + } + try: + owned = _outcome_record(parts) + except OutputError as error: + # Only now is each part checked alone, so that the error names the one to + # look at: the whole record fails exactly when one of its parts does. + name, why = next( + (name, why) + for name, part in parts.items() + if (why := _json_failure(part)) is not None + ) + msg = ( + "execution records must contain finite JSON values: the run " + f"outcome's {name} holds {why}" + ) + raise OutputError(msg) from error + for name in ("logical_calls", "atomic_calls", "execution"): + object.__setattr__(self, name, owned[name]) + object.__setattr__( + self, + "previous_executions", + tuple(cast("list[dict[str, object]]", owned["previous_executions"])), + ) + + +def _json_failure(part: object) -> str | None: + """Return why a part of a run outcome is not finite JSON, or ``None`` if it is.""" + try: + json.dumps(part, allow_nan=False) + except (TypeError, ValueError) as error: + return str(error) + return None + + +def _json_copy(value: object) -> object: + """Return an independent copy of a value made of validated JSON values. + + Cheaper than another JSON round trip, and than :func:`copy.deepcopy`, because the + value is known to hold only dicts, lists, strings, numbers, booleans and + ``None``. + """ + if isinstance(value, dict): + return {key: _json_copy(item) for key, item in value.items()} + if isinstance(value, list): + return [_json_copy(item) for item in value] + return value + + +def _amended_outcome( + outcome: RunOutcome, + *, + previous_executions: tuple[Mapping[str, object], ...], +) -> RunOutcome: + """Return a copy of an outcome with closing records a store has already validated. + + :func:`dataclasses.replace` would validate the whole record again; the store's + earlier closing records were validated when it took them, so a saved plane validates + its record once. The caller hands over copies of them (:func:`_json_copy`), so that + each outcome holds its own and a change to one read back reaches no other. + """ + amended = copy.copy(outcome) + object.__setattr__(amended, "previous_executions", previous_executions) + return amended + + +def _outcome_record(record: Mapping[str, object]) -> dict[str, object]: + """Own finite JSON values so later provider mutations cannot change a snapshot. + + - ``record``: execution facts composed of finite JSON values. + """ + try: + return cast( + "dict[str, object]", json.loads(json.dumps(dict(record), allow_nan=False)) + ) + except (TypeError, ValueError) as error: + raise OutputError( + f"execution records must contain finite JSON values: {error}" + ) from error + + +@dataclass(frozen=True, eq=False) +class TerminalCheckpoint: + """Keep an accepted field between scheduled saves without adding an observation. + + Attributes + ---------- + plane : SavedPlane + Accepted field in the internal FFT convention and the output amplitude + convention, with already available history and periodic statistics. + Creating a checkpoint never requests fresh material statistics. It always + carries the field, also in a run that keeps none at its planes, so that a + stopped run resumes (SEP 22, Q1). + next_save_index : int + Index of the first scheduled save strictly beyond this field. + landed : bool + Whether the field is an accepted endpoint rather than a dense observation. + """ + + plane: SavedPlane + next_save_index: int + landed: bool = True + + def __post_init__(self) -> None: + """Reject an unusable save index, a field-free or a nonfinite checkpoint.""" + if type(self.next_save_index) is not int or self.next_save_index < 0: + raise OutputError( + "checkpoint next_save_index must be a nonnegative integer" + ) + if self.plane.field is None: + raise OutputError( + "a terminal checkpoint carries the field, also in a run that keeps " + "none at its planes" + ) + if ( + not np.isfinite(self.plane.z) + or self.plane.z < 0 + or not np.all(np.isfinite(self.plane.field)) + ): + raise OutputError("checkpoint position and field must be finite") + + +@dataclass(frozen=True, eq=False) +class MarchSnapshot: # numpydoc ignore=PR02 + """An Adams march's state at an accepted position, for a resume to continue. + + What a resume needs to carry on as the uninterrupted run would: the accepted state + exactly as the backend held it, the history's samples in their engine rows, which + sample each history entry is, the order, the rejection count and the step the + controller proposed next. A snapshot is never the record of what was computed, only + of where a march stood: a resume continues it when its history key matches the + resuming method's, and otherwise starts the history afresh. + + Equality is by identity (``eq=False``) because the fields hold arrays. + + Attributes + ---------- + z : float + The accepted position, m. + state : numpy.ndarray + The accepted state, shape ``(n_modes, n_pol, n_omega)``, in the internal FFT + convention, exactly as the backend held it. A file stores it with a leading + axis of one, the layout it has always had (SEP 47 section 6.7). + proposal : float + The step the controller proposed next, m. + method : str + The method's representation, kept as provenance. + order : int + The order chosen for the next step. + rejections : int + Consecutive rejections so far. + last_rejected : bool + Whether the last trial was rejected. + positions : numpy.ndarray + Each history entry's position, m, oldest first. + identities : tuple of str + Each entry's kind (``"accepted"`` or ``"predicted"``). + slots : numpy.ndarray + Each entry's engine row. + rows : collections.abc.Mapping + The engine's rows by name: the samples (``"store"``) and, for a compensated + exponential march, the sampled states and rates and each sample's flow + weights, in the engine's own layout, which keeps a leading axis of one after + the row axis. + collar_was_quiet : bool + Whether the in-stage collar was left out, quiet, at the last attempt, which + holds the first step after it returns to its stable step (SEP 39 section + 6.5.9). + history_key : str or None + What defines the history + (:attr:`solprop.stepper.multistep.Multistep.history_key`), which the resuming + method's must equal; ``None`` for a snapshot that names none, which starts the + history afresh. + + Raises + ------ + OutputError + If the entries disagree in number, or a position or the state is not finite. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import MarchSnapshot + >>> snapshot = MarchSnapshot( + ... z=0.5, state=np.ones((1, 1, 4), complex), proposal=0.1, method="M()", + ... order=2, rejections=0, last_rejected=False, + ... positions=np.array([0.4, 0.5]), identities=("accepted", "predicted"), + ... slots=np.array([0, 1]), rows={"store": np.zeros((4, 1, 1, 1, 4), complex)}, + ... ) + >>> snapshot.nbytes + 320 + """ + + z: float + state: Complex[np.ndarray, "n_modes n_pol n_omega"] + proposal: float + method: str + order: int + rejections: int + last_rejected: bool + positions: Float[np.ndarray, " n_entries"] + identities: tuple[str, ...] + slots: Int[np.ndarray, " n_entries"] + rows: Mapping[str, np.ndarray] + collar_was_quiet: bool = False + history_key: str | None = None + + def __post_init__(self) -> None: + """Refuse a snapshot whose entries disagree or whose numbers are not finite. + + Raises + ------ + OutputError + Naming the inconsistency. + """ + count = len(self.identities) + if self.positions.shape != (count,) or self.slots.shape != (count,): + raise OutputError("march snapshot entries disagree in number") + if not ( + math.isfinite(self.z) + and math.isfinite(self.proposal) + and np.all(np.isfinite(self.positions)) + and np.all(np.isfinite(self.state)) + ): + raise OutputError("march snapshot positions and state must be finite") + + @property + def nbytes(self) -> int: # numpydoc ignore=RT01 + """The bytes the snapshot's arrays hold. + + The state and every engine row. + """ + return int(self.state.nbytes + sum(row.nbytes for row in self.rows.values())) + + +def _holds_carried(plane: SavedPlane, carried: SavedPlane) -> bool: + """Return whether a checkpoint already begins with the attempts a resume carried. + + The checkpoint a store holds after a resume, or a copy of it, starts with the + carried attempts, every column equal; a new one from the resumed march starts + where the resume did, with attempts of its own and its own wall clock. + + - Returns: True when `plane`'s history and rows start with `carried`'s, or `carried` + holds nothing to add. + """ + before, history = carried.step_history, plane.step_history + if before.n_steps == 0: + return carried.step_stats.n_rows == 0 + if history.n_steps < before.n_steps: + return False + return all( + np.array_equal(getattr(history, name)[: before.n_steps], getattr(before, name)) + for name in HISTORY_FIELDS + ) + + +def _prepend_checkpoint_history( + previous: SavedPlane, current: SavedPlane +) -> SavedPlane: + """Retain attempts and periodic rows preceding a fresh-start resume.""" + history = StepHistory( + **{ + name: np.concatenate( + ( + getattr(previous.step_history, name), + getattr(current.step_history, name), + ) + ) + for name in HISTORY_FIELDS + } + ) + before, after = previous.step_stats, current.step_stats + if before.n_rows and after.n_rows and before.names != after.names: + raise OutputError("checkpoint periodic statistic names changed on resume") + names = before.names if before.n_rows else after.names + values = {} + for name in names: + if before.n_rows and after.n_rows: + values[name] = np.concatenate((before.values[name], after.values[name])) + else: + values[name] = before.values[name] if before.n_rows else after.values[name] + rows = StepStats( + z=np.concatenate((before.z, after.z)), + step=np.concatenate((before.step, after.step + previous.step_history.n_steps)), + values=values, + ) + return dataclasses.replace(current, step_history=history, step_stats=rows) + + +@dataclass(frozen=True) +class RecipeFile: + """A data file a recorded recipe refers to (SEP 41, S2). + + Attributes + ---------- + path : str + The path the recipe wrote, relative to its directory. + sha256 : str + Its SHA-256, as the recipe recorded it. + data : bytes or None + Its contents, embedded under the generated name it is filed by; ``None`` + when the run's files together passed the embedding cap, and only the path and + hash are kept. + """ + + path: str + sha256: str + data: bytes | None = None + + +@dataclass(frozen=True) +class RecipeRecord: + """The recipe a run was built from, as its output records it once (SEP 41, S2). + + Provenance outside the run record and its hash: a file without one is a file from + a script, and a reader that does not know it ignores it. + + Attributes + ---------- + format : int + The recipe format. + authored : str + The recipe's text as loaded, or its canonical TOML if it was edited. + resolved : str + The recipe to rebuild the run from: the authored keys with the drawn seed, the + keywords whose value written back gives the same record, at the values used, + and the execution settings at run start. + files : collections.abc.Mapping + The data files the recipe refers to, by the generated name each is filed by. + + Examples + -------- + >>> from solprop.output.records import RecipeRecord + >>> record = RecipeRecord(1, "[recipe]", "[recipe]", {}) + >>> record.authored_sha256 == record.resolved_sha256 + True + """ + + format: int + authored: str + resolved: str + files: Mapping[str, RecipeFile] = dataclasses.field(default_factory=dict) + + @property + def authored_sha256(self) -> str: # numpydoc ignore=RT01 + """SHA-256 of the authored text, in hexadecimal.""" + return hashlib.sha256(self.authored.encode("utf-8")).hexdigest() + + @property + def resolved_sha256(self) -> str: # numpydoc ignore=RT01 + """SHA-256 of the resolved text, in hexadecimal.""" + return hashlib.sha256(self.resolved.encode("utf-8")).hexdigest() diff --git a/src/solprop/output/resumable.py b/src/solprop/output/resumable.py index a0b4e97a..f2fce4fb 100644 --- a/src/solprop/output/resumable.py +++ b/src/solprop/output/resumable.py @@ -27,7 +27,9 @@ from solprop.exceptions import OutputError, SolpropError from solprop.output._writer_lock import WriterLock from solprop.output.hdf5 import ResumeState, _file_path, resume -from solprop.output.schema import SCHEMA_VERSION, Output, _decode +from solprop.output.metadata import SCHEMA_VERSION +from solprop.output.serialisation import _decode +from solprop.output.store import Output __all__ = ["Resumability", "ResumeMode", "resumability"] @@ -139,7 +141,7 @@ def resumability(path: str | Path | Output, *, gate: bool = False) -> Resumabili Parameters ---------- - path : str or pathlib.Path or solprop.output.schema.Output + path : str or pathlib.Path or solprop.output.store.Output The HDF5 output; an output handler is refused by name. gate : bool, optional Also rebuild the run from its recorded recipe and compare the records' hashes, diff --git a/src/solprop/output/scan.py b/src/solprop/output/scan.py index c62a7fc4..278930f9 100644 --- a/src/solprop/output/scan.py +++ b/src/solprop/output/scan.py @@ -24,13 +24,9 @@ from solprop.exceptions import OutputError, ProcessingError from solprop.output._scan_lock import read_scan, write_scan from solprop.output.hdf5 import _dataset, _group, write_recipe -from solprop.output.schema import ( - RecipeRecord, - RunMetadata, - _inlined, - _refuse_schema, - serialise, -) +from solprop.output.metadata import RunMetadata, _refuse_schema +from solprop.output.records import RecipeRecord +from solprop.output.serialisation import _inlined, serialise logger = logging.getLogger(__name__) @@ -93,7 +89,7 @@ class PointResult: Complete run metadata, empty if construction failed. metadata_arrays : dict The arrays the metadata names - (:meth:`solprop.output.schema.RunMetadata.record_arrays`). + (:meth:`solprop.output.metadata.RunMetadata.record_arrays`). arrays : dict Named retained fields, statistics and reduction arrays. reductions : dict @@ -254,7 +250,7 @@ def record_recipe(self, record: RecipeRecord | None) -> None: Parameters ---------- - record : solprop.output.schema.RecipeRecord or None + record : solprop.output.records.RecipeRecord or None The recipe; ``None``, a scan from a script, records none. """ if record is not None and "recipe" not in self.file: diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py index c9df37bf..19005529 100644 --- a/src/solprop/output/schema.py +++ b/src/solprop/output/schema.py @@ -2,5072 +2,68 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""Run metadata, saved-plane records and the one save protocol both handlers implement. - -An output file has to answer one question years later: *what produced this?* The record -types here are that answer. :class:`RunMetadata` carries the grid, the modes, the -serialised physics configuration, the package versions, the git provenance of the -running checkout, the noise seed and realisation, and the stepper settings; -:class:`SavedPlane` carries one saved position with its field, its statistics and the -step history since the previous plane. :class:`Output` is the protocol -:class:`solprop.output.memory.MemoryOutput` and :class:`solprop.output.hdf5.HDF5Output` -both implement, so processing reads either through the same accessors. - -**Conventions on the way out.** A ``SavedPlane`` holds its field in the internal FFT -convention, as a backend produces it; a handler converts it with -:func:`solprop.fourier.spectrum_to_optics` on the way to storage, so that what is stored -is the window-centred optics spectrum a spectrometer would agree with, marked -``spectral_convention = "optics"`` (``docs/dev/conventions.md`` §2; this module -and the handlers are boundary place 2). The noise realisation and ``noise_phase`` are -internal objects and stay in the FFT convention, marked so by -:data:`INTERNAL_CONVENTION`. - -**Provenance, not source.** The provenance is the resolved configuration, the -package versions and the git commit with a dirty flag -- never the source text. - -**The resume key.** :meth:`RunMetadata.hashed` is the part of the record that says what -was computed: the schema version, the grid, the modes, the configuration, the noise, the -stepper settings with the stepping method when it is not the tableau (SEP 13), and the -identities and policies of the run's material manifest, and nothing else: not the -wall-clock time the run started, not the package versions, and not the git provenance. -:meth:`RunMetadata.configuration_hash` is a SHA-256 of its canonical JSON rather than -:func:`hash`, so it is stable across processes; it is stamped in every file and is the -exact identity a resume, a suspended writer, a snapshot and a scan row are checked by -(ADR-066): a resume with different physics or numerics of the same shape is refused, -naming the fields that changed (:meth:`RunMetadata.summarise_differences`). A record -read from a file is compared as it was written. - -The manual page is ``docs/guide/results/output.md``. +"""TEMPORARY: the old import path of the records split into five modules; delete it. + +This module was split into :mod:`solprop.output.serialisation`, +:mod:`solprop.output.provenance`, :mod:`solprop.output.records`, +:mod:`solprop.output.metadata` and :mod:`solprop.output.store`. It re-exports only the +names that ``driver/simulation.py`` and ``driver/interface.py`` still import from +here while another branch splits those files; the merge rewrites them and deletes this +module. """ -import copy -import dataclasses -import functools -import hashlib -import json -import logging -import math -import platform -import re -import subprocess -import weakref -from abc import ABC, abstractmethod -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from datetime import UTC, datetime -from enum import Enum -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as _package_version -from pathlib import Path -from typing import Any, ClassVar, Literal, Protocol, cast, runtime_checkable - -import numpy as np -from jaxtyping import Bool, Complex, Float, Int -from scipy.interpolate import CubicSpline - -from solprop import fourier -from solprop._display import Displayed, engineering, line, summarise -from solprop._process import parallel_rows -from solprop.exceptions import MaterialError, OutputError -from solprop.grid import EnvelopeGrid, TimeGrid -from solprop.modes.material import JsonValue -from solprop.modes.protocol import Mode, TransverseMeasure -from solprop.noise import NoiseRealisation -from solprop.physdata.selection import MaterialManifest, hashed_document -from solprop.stepper import FixedSchedule, PIController, Tableau +from solprop.output.metadata import ( + INTERPOLATED_RESUMES_ANNOTATION, + PEAK_FIELDS_ANNOTATION, + RunMetadata, +) +from solprop.output.records import ( + DefaultsResolution, + GridRecord, + MarchSnapshot, + ModeRecord, + NoiseRecord, + NoiseResolution, + RecipeRecord, + StepLimits, + StepperRecord, +) +from solprop.output.serialisation import ( + StatedInput, + _recorded_inline, # pyright: ignore[reportPrivateUsage] - for simulation.py + recorded_input, + serialise, +) +from solprop.output.store import ( + CheckpointOutput, + FieldRetention, + FieldsChoice, + Output, + PlaneStore, + field_retention_refusal, +) __all__ = [ - "FIELD_LAYOUT", - "HISTORY_FIELDS", - "INLINE_ARRAY_LIMIT", - "INTERNAL_CONVENTION", "INTERPOLATED_RESUMES_ANNOTATION", "PEAK_FIELDS_ANNOTATION", - "RECORD_ARRAY_LIMIT", - "RUN_SUMMARY_ANNOTATION", - "SCHEMA_VERSION", - "SPECTRAL_CONVENTION", - "STEP_STATS_INDEX", - "ArrayDigest", - "BackendRecord", "CheckpointOutput", - "Convention", "DefaultsResolution", "FieldRetention", "FieldsChoice", - "GitProvenance", "GridRecord", - "JsonValue", "MarchSnapshot", "ModeRecord", "NoiseRecord", "NoiseResolution", "Output", - "PlaneLayout", "PlaneStore", - "RecipeFile", "RecipeRecord", - "RetainingOutput", "RunMetadata", - "RunOutcome", - "SavedPlane", - "SimulationKind", "StatedInput", - "StepHistory", "StepLimits", - "StepStats", "StepperRecord", - "StoredField", - "TerminalCheckpoint", + "_recorded_inline", "field_retention_refusal", - "package_versions", "recorded_input", - "resolve_metadata_write", "serialise", - "stored_spectrum", ] - -logger = logging.getLogger(__name__) - -_DIFFERENCES_SHOWN = 4 -"""How many changed fields a refusal names in each part of the record before it counts -the rest of that part.""" - -_PATH_SEPARATOR = re.compile(r"[.\[]") -"""What ends a changed path's top-level key, the part of the record it belongs to.""" - -_ELEMENT_INDICES = re.compile(r"(?:\[\d+\])+$") -"""The trailing indices of a changed path into an array's element.""" - -_ARRAY_PART = re.compile(r"\.__(?:ndarray|imaginary)__$") -"""The key of an array record's real or imaginary part, dropped from its stem.""" - -SCHEMA_VERSION = 2 -"""Version of the saved-output layout; incremented when a stored field changes meaning. - -Schema 1 is the first released layout, 0.1.0's: the record types of this module, written -by :class:`solprop.output.memory.MemoryOutput` and -:class:`solprop.output.hdf5.HDF5Output` as ``docs/guide/results/output.md`` describes -them. Schema 2 records ``DataPulse.spectral_phase``. A record that declares any other -version is refused by name (:class:`RunMetadata`): files written by an earlier release -or by development versions of solprop, which declared output schemas up to 28, are not -read, and nor is a file from a later release. Read such a file with the solprop version -that wrote it. -""" - -RECORD_ARRAY_LIMIT = 4096 -"""The most elements a numeric array of a run record holds as JSON (#309). - -A larger one -- a per-node weight, a flux plan's coefficients -- is recorded by a -reference holding its digest, dtype and shape, and its bytes are stored beside the -record, named by that digest (:meth:`RunMetadata.record_arrays`). So the hash takes the -array's bytes rather than its text, which costs milliseconds rather than seconds, and -the array stays readable. The limit keeps a grid's axes in the text, for a person -reading it. -""" - -_RECORD_ARRAY = "__record_array__" -"""The key of a reference to an array stored beside the record.""" - -INLINE_ARRAY_LIMIT = 2**20 -"""The most elements an array input is recorded with inline (SEP 22, Q4). - -A larger array -- a gigabyte spatial launch -- is recorded by an :class:`ArrayDigest` -of its bytes, dtype and shape instead, which costs seconds of hashing per input rather -than tens of gigabytes of JSON, and still changes with any one sample. A caller may -state its identity instead, with a :class:`StatedInput`. -""" - -SPECTRAL_CONVENTION = "optics" -"""Sign convention of every spectrum a handler stores.""" - -PEAK_FIELDS_ANNOTATION = "material_peak_fields" -"""Name of the post-run annotation holding the material manifest's peak-field report. - -A list with one plain mapping per population with an optmat ionisation rate: -the largest field it reached against its model's maximum field, and the energy per -event there against the constant the run paid -(:class:`solprop.nonlinear.optmat_rates.PeakFieldReport`). Written by -:meth:`solprop.driver.simulation.Simulation.run` when a run closes, outside the -configuration hash (SEP 3, Q13). -""" - -INTERPOLATED_RESUMES_ANNOTATION = "interpolated_resumes" -"""Name of the post-run annotation listing where a dense run was resumed, m. - -A list of the positions of the interpolated save planes a run observed densely was -continued from with ``resume="interpolated"``: each continuation started afresh from a -field interpolated inside a step, not a state the march passed through (SEP 14). -Written by :meth:`solprop.driver.simulation.Simulation.run`, outside the configuration -hash. -""" - -RUN_SUMMARY_ANNOTATION = "run_summary" -"""Name of the annotation holding the start-of-run summaries of a plane store. - -A list with one mapping per start, ``{"title": str, "rows": [[key, value], ...]}``: a -run that is resumed adds the summary of its continuation to the first one's. Written -when a run starts, not when it closes, and outside the configuration hash. -""" - -INTERNAL_CONVENTION = "fft" -"""Sign convention of the arrays that stay internal: the noise field and its phase.""" - -FIELD_LAYOUT = "(n_z, n_modes, n_pol, n_omega)" -"""Axis order of the stored field, as ``docs/dev/conventions.md`` §6 fixes it.""" - -SimulationKind = Literal["mode_averaged", "multimode", "radial", "free", "slab"] -"""Which transform the run used.""" - -Convention = Literal["optics", "fft"] -"""Which sign convention an accessor should hand back.""" - -# JsonValue is imported above and re-exported here, where the file format is -# owned. It is defined in solprop.modes.material because this module imports -# solprop.modes.protocol: defining it here and importing it there would close a -# cycle through the output layer. - -# The subprocess calls that read the git provenance are bounded: a hung git must not -# stall a run that is otherwise ready to start. -_GIT_TIMEOUT = 5.0 - - -# ------------------------------------------------------------------- serialisation - - -def _tag_unreproducible(value: object, key: str) -> dict[str, JsonValue]: - """Return the record of an object that cannot be rebuilt from the file. - - - ``key``: tag naming why it is irreducible, ``'__callable__'`` or ``'__repr__'``. - - Returns: a record holding the object's ``repr`` and ``reproducible: False``. - """ - logger.debug("configuration entry recorded by repr only: %s", key) - return {key: repr(value), "reproducible": False} - - -def _serialise_dataclass(value: object) -> dict[str, JsonValue]: - """Return a dataclass instance as a tagged dictionary of its constructor fields. - - Fields declared ``init=False`` are derived in ``__post_init__`` from the ones that - are, so recording them would store the same information twice and make two runs - that differ in nothing hash differently if a derived quantity ever changes form. - - A field whose metadata says ``record=False`` is a live handle that the record - carries elsewhere, and is left out for the same reason: a provider channel's - :class:`~solprop.modes.guide.ChannelSource` holds the provider object, whose - identity the run records once beside its channels. - - - Returns: ``{'__class__': 'module.QualName', : , ...}``. - """ - kind = type(value) - name = f"{kind.__module__}.{kind.__qualname__}" - record: dict[str, JsonValue] = {"__class__": name} - # The parameter stays `object` because the test session resolves annotations at - # run time, and `fields` wants typeshed's `DataclassInstance`, which does not exist - # there; the caller has already checked that this is a dataclass instance. - for entry in dataclasses.fields(value): # type: ignore[arg-type] - checked by the caller - if not entry.init or not entry.metadata.get("record", True): - continue - record[entry.name] = serialise(getattr(value, entry.name)) - return record - - -def _serialise_enum(value: Enum) -> dict[str, JsonValue]: - """Return an enumeration member as its qualified class name and its value. - - The class matters as much as the value: two enumerations may share a member value - and mean different things, and the configuration hash has to tell them apart. - - - Returns: ``{'__enum__': 'module.QualName', 'value': }``. - """ - kind = type(value) - return { - "__enum__": f"{kind.__module__}.{kind.__qualname__}", - "value": serialise(value.value), - } - - -class _ArrayReference(dict): - """A reference to a record array, in its JSON form, carrying the array it names. - - A serialised record is JSON-ready, so a reference is a plain mapping to :mod:`json`, - and the array travels with it until the record is stored - (:meth:`RunMetadata.record_arrays`). - - Attributes - ---------- - array : numpy.ndarray - The array. - """ - - def __init__(self, values: np.ndarray) -> None: - """Digest the array and hold it.""" - digest = ArrayDigest.of(values) - super().__init__( - { - _RECORD_ARRAY: _array_name(digest), - "dtype": digest.dtype, - "shape": list(digest.shape), - } - ) - self.array = values - - -def _serialise_numpy(value: np.ndarray | np.generic) -> JsonValue: - """Return a NumPy array as a nested list with its dtype, or a scalar as a scalar. - - A complex array is split into its real and imaginary parts, because JSON has no - complex number and a nested list of them would not survive a round trip. A numeric - array of more than :data:`RECORD_ARRAY_LIMIT` elements is a reference instead. - - - Returns: a ``__ndarray__`` record for an array, and the plain Python value of a - scalar. - """ - if isinstance(value, np.generic): - return serialise(value.item()) - if value.size > RECORD_ARRAY_LIMIT and value.dtype.kind in "biufc": - return _ArrayReference(value) - return _inline_array(value) - - -def _inline_array(value: np.ndarray) -> dict[str, JsonValue]: - """Return an array as a ``__ndarray__`` record, whatever its size. - - - Returns: its dtype, its shape and its values. - """ - record: dict[str, JsonValue] = { - "dtype": str(value.dtype), - "shape": list(value.shape), - } - if np.iscomplexobj(value): - record["__ndarray__"] = np.real(value).tolist() - record["__imaginary__"] = np.imag(value).tolist() - else: - # tolist gives JSON primitives for every numeric and boolean dtype; anything - # else goes back through serialise, which records what it cannot reduce. - record["__ndarray__"] = serialise(value.tolist()) - return record - - -def _inlined(value: JsonValue) -> JsonValue: - """Return a serialised structure with each array reference's values in its text. - - For text with no store beside it, which has to stand alone: only a run record keeps - its arrays beside its text (:meth:`RunMetadata.record_arrays`). A reference already - read back from text carries no array and stays as it is. - """ - if isinstance(value, _ArrayReference): - return _inline_array(value.array) - if isinstance(value, dict): - return {key: _inlined(item) for key, item in value.items()} - if isinstance(value, list): - return [_inlined(item) for item in value] - return value - - -def _serialise_sequence(value: object) -> list[JsonValue]: - """Return a sequence or a set as a list, ordering set members deterministically. - - A set's members are sorted by their canonical JSON text, so that the configuration - hash does not depend on set iteration order. - """ - # `object` is not declared iterable; the caller has already checked that this is - # a set or a sequence. - members = [serialise(item) for item in value] # type: ignore[union-attr] - checked by the caller - if isinstance(value, set | frozenset): - return sorted(members, key=_canonical_json) - return members - - -def _serialise_object(value: object) -> JsonValue: - """Return a structured or opaque object as a JSON-safe structure.""" - if isinstance(value, Path): - return str(value) - if type(value) is CubicSpline: - # Material tables own these interpolants. Their repr contains an address, - # so record the complete polynomial instead of an irreproducible callable. - return { - "__class__": "scipy.interpolate.CubicSpline", - "x": serialise(value.x), - "c": serialise(value.c), - "axis": serialise(value.axis), - "extrapolate": serialise(value.extrapolate), - } - if dataclasses.is_dataclass(value) and not isinstance(value, type): - return _serialise_dataclass(value) - if isinstance(value, Mapping): - return {str(key): serialise(item) for key, item in value.items()} - if isinstance(value, set | frozenset | Sequence) and not isinstance( - value, str | bytes - ): - return _serialise_sequence(value) - return _tag_unreproducible(value, "__callable__" if callable(value) else "__repr__") - - -def serialise(value: object) -> JsonValue: - """Return any configuration object as a JSON-safe structure, dropping nothing. - - Dataclasses become tagged dictionaries of their constructor fields (less a live - handle whose field metadata says ``record=False``), enumerations - their qualified name and value, NumPy arrays a nested list with their dtype and - shape, or above :data:`RECORD_ARRAY_LIMIT` elements a reference to their bytes, - cubic splines their knots, coefficients, axis and extrapolation setting, - complex numbers a real/imaginary pair, and mappings and sequences are - recursed. Anything that cannot be reduced -- a function, a lambda, an object with - no structure -- is recorded as its ``repr`` and marked ``reproducible: False``, - because a configuration that is silently incomplete is worse than one that says - where it stops. - - The function is idempotent: applying it to its own output returns that output, so - a round-tripped :class:`RunMetadata` hashes exactly as the original does. - - Parameters - ---------- - value : object - Anything at all. - - Returns - ------- - NoneType or bool or int or float or str or list or dict - A structure of JSON primitives, lists and string-keyed dictionaries. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import serialise - >>> serialise({"radius": 125e-6}) - {'radius': 0.000125} - >>> serialise(np.array([1.0, 2.0]))["dtype"] - 'float64' - >>> serialise(abs)["reproducible"] - False - """ - if value is None or isinstance(value, bool | _ArrayReference): - return value - if isinstance(value, Enum): - return _serialise_enum(value) - if isinstance(value, int | float | str): - return value - if isinstance(value, complex): - return {"__complex__": [value.real, value.imag]} - if isinstance(value, np.ndarray | np.generic): - return _serialise_numpy(value) - return _serialise_object(value) - - -_DIGEST_BLOCK = 2**16 -"""Elements a digest of an array not held in its order reads at a time.""" - - -@dataclass(frozen=True) -class ArrayDigest: - """An array recorded by the digest of its contents rather than inline. - - What a run record holds for an array input above :data:`INLINE_ARRAY_LIMIT` - elements (SEP 22 section 5.2, Q4): the SHA-256 of its bytes in little-endian order, - with its dtype and shape. Two inputs that differ in one sample record different - digests, so their runs have different configuration hashes; the array itself is - not recoverable from the record. - - Attributes - ---------- - sha256 : str - Sixty-four lowercase hexadecimal characters. - dtype : str - The array's dtype, as NumPy names it (``"complex128"``). - shape : tuple of int - The array's shape. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import ArrayDigest - >>> digest = ArrayDigest.of(np.zeros((2, 3))) - >>> digest.dtype, digest.shape, len(digest.sha256) - ('float64', (2, 3), 64) - """ - - sha256: str - dtype: str - shape: tuple[int, ...] - - @classmethod - def of(cls, values: np.ndarray) -> "ArrayDigest": - """Return the digest of an array's contents. - - Parameters - ---------- - values : numpy.ndarray - The array; read once, in C order and little-endian byte order, a block at - a time unless it is held that way already, so that a broadcast input never - becomes a whole volume. - - Returns - ------- - ArrayDigest - The digest, the dtype and the shape. - """ - array = np.asarray(values) - # One byte order, so that the same numbers digest alike on every platform. - little = array.dtype.newbyteorder("<") - digest = hashlib.sha256() - if array.size == 0: - pass - elif array.flags.c_contiguous and array.dtype == little: - digest.update(memoryview(array).cast("B")) - else: - # NumPy's stubs leave "contig" out of the flags they list. - for block in np.nditer( # pyright: ignore[reportCallIssue, reportUnknownVariableType] - array, - flags=["external_loop", "buffered"], - op_flags=["readonly", "contig"], # pyright: ignore[reportArgumentType] - op_dtypes=[little], - casting="equiv", - order="C", - buffersize=_DIGEST_BLOCK, - ): - digest.update(np.asarray(block).tobytes()) - return cls( - sha256=digest.hexdigest(), - dtype=str(little), - shape=tuple(int(size) for size in array.shape), - ) - - -def _array_name(digest: ArrayDigest) -> str: - """Return the name a record array is stored under. - - The layout is part of the name because the digest covers only the bytes: arrays of - equal bytes and different dtypes or shapes are different arrays, and one must not - take the other's place in the store. - - - ``digest``: the array's digest, dtype and shape. - """ - shape = "x".join(str(size) for size in digest.shape) - return f"{digest.sha256}-{digest.dtype}-{shape}" - - -@dataclass(frozen=True) -class StatedInput: - """An array input recorded by the identity its caller states, its bytes unread. - - What a run record holds under ``input_identity`` - (:meth:`solprop.driver.simulation.Simulation.with_input`): for a caller that - already identifies its input, such as by a scan's delay and its own configuration, - and has no use for a digest of it. The identity is taken on trust: unlike an - :class:`ArrayDigest`, two arrays that differ record the same thing if they are - stated alike, so a resume and a scan's cache accept one for the other. - - Attributes - ---------- - identity : str - What the caller says the input is. - dtype : str - The array's dtype, as NumPy names it (``"complex128"``), little-endian as an - :class:`ArrayDigest` records it. - shape : tuple of int - The array's shape. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StatedInput - >>> StatedInput.of(np.zeros((2, 3), complex), "delay 12 fs") - StatedInput(identity='delay 12 fs', dtype='complex128', shape=(2, 3)) - """ - - identity: str - dtype: str - shape: tuple[int, ...] - - @classmethod - def of(cls, values: np.ndarray, identity: str) -> "StatedInput": - """Return the record of an array input under a stated identity. - - Parameters - ---------- - values : numpy.ndarray - The array, of which only the dtype and the shape are read. - identity : str - What the caller says it is. - - Returns - ------- - StatedInput - The identity, the dtype and the shape. - """ - return cls( - identity=identity, - dtype=str(values.dtype.newbyteorder("<")), - shape=tuple(int(size) for size in values.shape), - ) - - -def recorded_input(value: object) -> object: - """Return an input as the run record carries it: inline, or by its digest. - - Parameters - ---------- - value : object - A pulse, a spatial pulse or an array input. - - Returns - ------- - object - `value` itself, unless it is an array of more than :data:`INLINE_ARRAY_LIMIT` - elements, which is recorded as its :class:`ArrayDigest`. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import recorded_input - >>> recorded_input(np.zeros(4)).shape - (4,) - >>> type(recorded_input(np.zeros(2**20 + 1))).__name__ - 'ArrayDigest' - """ - if isinstance(value, np.ndarray) and not _recorded_inline(value): - return ArrayDigest.of(value) - return value - - -def _recorded_inline(values: np.ndarray) -> bool: - """Return whether a run record carries an array input itself, not its digest. - - - Returns: True for at most :data:`INLINE_ARRAY_LIMIT` elements. - """ - return values.size <= INLINE_ARRAY_LIMIT - - -def _canonical_json(value: JsonValue) -> str: - """Return the one textual form of a structure that a digest may be taken over.""" - return json.dumps(value, sort_keys=True, separators=(",", ":")) - - -def _decode_array(record: Mapping[str, object]) -> np.ndarray: - """Return the array a ``__ndarray__`` record describes. - - The nested list :meth:`numpy.ndarray.tolist` produced already carries the shape, so - only the dtype has to be restored, and the imaginary part rejoined when there is - one. - - - Returns: the array, with its original dtype and shape. - """ - dtype = np.dtype(str(record["dtype"])) - if "__imaginary__" in record: - real = np.array(record["__ndarray__"], dtype=np.float64) - # Assigning the two views rather than forming real + 1j*imaginary: complex - # arithmetic does not preserve a signed zero, and the digest is taken over the - # text of these numbers, where -0.0 and 0.0 differ. - values = np.empty(real.shape, dtype=dtype) - values.real = real - values.imag = np.array(record["__imaginary__"], dtype=np.float64) - return values - return np.array(record["__ndarray__"], dtype=dtype) - - -def _decode(value: object) -> object: - """Return a serialised value with an array field rebuilt. - - An array is the only form a record type holds that JSON cannot carry directly, so - it is the only one rebuilt; a nested configuration dictionary is left as plain - JSON, because the physics objects it describes are not reconstructible in general - (a callable was recorded by ``repr``). - - - Returns: the rebuilt value, or ``value`` itself when there is nothing to rebuild. - """ - if isinstance(value, dict) and "__ndarray__" in value: - return _decode_array(value) - return value - - -def _resolved( - value: JsonValue, - arrays: Mapping[str, object], - found: dict[str, np.ndarray], -) -> object: - """Return a stored document with each array reference replaced by its array.""" - if isinstance(value, list): - return [_resolved(item, arrays, found) for item in value] - if not isinstance(value, dict): - return value - if _RECORD_ARRAY not in value: - return {key: _resolved(item, arrays, found) for key, item in value.items()} - name = str(value[_RECORD_ARRAY]) - if name not in found: - if name not in arrays: - msg = ( - f"stored metadata names the array {name}, which is not beside it: " - "read a record through the file that holds it" - ) - raise OutputError(msg) - array = np.asarray(arrays[name]) - if _array_name(ArrayDigest.of(array)) != name: - msg = f"the array stored as {name} does not match that name" - raise OutputError(msg) - found[name] = array - return found[name] - - -def _dotted(path: Sequence[str | int]) -> str: - """Return a path into a stored record as a refusal writes it.""" - text = "" - for part in path: - text += f"[{part}]" if isinstance(part, int) else f".{part}" if text else part - return text - - -def _stored_entry( - document: Mapping[str, object], - name: str, - path: tuple[str, ...], - *, - default: object = None, - required: bool = False, -) -> object: - """Return one entry of a stored record, reading an absent one as `default`.""" - if name in document: - return document[name] - if required: - msg = f"stored metadata lacks {_dotted((*path, name))}" - raise OutputError(msg) - return default - - -def _rebuild[T]( - build: type[T], - record: Mapping[str, object], - path: tuple[str | int, ...] = (), -) -> T: - """Return a record instance from the dictionary :func:`serialise` made of it. - - The instance is for reading, so a file another build of the same output schema - wrote stays readable: a field the class no longer has is dropped, and an absent - field takes the field's default. A resume never compares the rebuilt instance: it - compares the stored text (:meth:`RunMetadata.configuration_hash`). - """ - fields = {entry.name: entry for entry in dataclasses.fields(build) if entry.init} # pyright: ignore[reportArgumentType] - a record dataclass - arguments = {name: _decode(item) for name, item in record.items() if name in fields} - for name, entry in fields.items(): - if ( - name not in arguments - and entry.default is dataclasses.MISSING - and entry.default_factory is dataclasses.MISSING - ): - msg = ( - f"stored metadata lacks {_dotted((*path, name))}, which this build " - "requires" - ) - raise OutputError(msg) - return build(**arguments) - - -# ---------------------------------------------------------------------- provenance - - -def _run_git(arguments: Sequence[str], root: Path) -> str | None: - """Return the output of one read-only git command, or ``None`` if it cannot run. - - - Returns: the stripped standard output, or ``None`` when git is absent, the - directory is not a repository, or the command fails or times out. - """ - try: - finished = subprocess.run( - ["git", *arguments], - cwd=root, - capture_output=True, - text=True, - timeout=_GIT_TIMEOUT, - check=False, - ) - except OSError, subprocess.SubprocessError: - return None - if finished.returncode != 0: - return None - return finished.stdout.strip() - - -@dataclass(frozen=True) -class BackendRecord: - """What executed a run, as the backend's own ``metadata()`` reported it. - - A saved run has to say what produced it and not only what was computed: the thread - configuration alone moved one step by a factor of nine - (``docs/dev/backends.md`` section 3.4), and without this record a timing - comparison has to read the realised kernel pool out of the backend itself. - - It is **outside the resume key**, like the package versions and the git provenance: - a run continued on the other backend agrees with an uninterrupted one to - 2.2-2.7e-16 and is the same run. What it records is how, - not what. - - A resumed file keeps the record it was opened with, creation time and all, so the - backend it names is the one that **started** the run. That is append mode's rule - for the whole record and not a special case for this group. The name may be one this - package no longer has, such as ``numba_cpu``: a recorded name is not a request, so - an old file's record reads as it was written, in its backend's own vocabulary. - - Attributes - ---------- - name : str - The registry name of the backend, for example ``"numpy_ref"`` or ``"native"``. - propagator_mode : str - Which the backend resolved the contract's ``"auto"`` to, or which was asked - for: ``"tabulate"`` or ``"recompute"``. - settings : dict - Everything else the backend reported, for example its thread counts, its plan - flag and its wisdom digest. Free-form because - it is a backend's own vocabulary, and JSON-serialisable because it is written - to the file. - - Examples - -------- - >>> from solprop.output.schema import BackendRecord - >>> record = BackendRecord.from_metadata( - ... {"backend": "native", "propagator_mode": "recompute", - ... "thread_count": 4} - ... ) - >>> record.name, record.propagator_mode, record.settings - ('native', 'recompute', {'thread_count': 4}) - """ - - name: str - propagator_mode: str - settings: Mapping[str, JsonValue] = dataclasses.field(default_factory=dict) - - def __post_init__(self) -> None: - """Adopt a plain copy of the free-form settings.""" - object.__setattr__(self, "settings", dict(self.settings)) - - @classmethod - def from_metadata(cls, metadata: Mapping[str, object]) -> "BackendRecord": - """Return the record a backend's ``metadata()`` describes. - - Parameters - ---------- - metadata : collections.abc.Mapping - What :meth:`solprop.backends.protocol.Backend.metadata` returned. - ``backend`` and ``propagator_mode`` are named fields here; everything else - becomes `settings`. - - Returns - ------- - BackendRecord - The record. - - Raises - ------ - OutputError - If either named field is missing. - """ - try: - name = str(metadata["backend"]) - mode = str(metadata["propagator_mode"]) - except KeyError as error: - msg = ( - "a backend's metadata must name the backend and the propagator mode; " - f"this one is missing {error}" - ) - raise OutputError(msg) from error - rest = { - key: cast("JsonValue", value) - for key, value in metadata.items() - if key not in {"backend", "propagator_mode"} - } - return cls(name=name, propagator_mode=mode, settings=rest) - - -@dataclass(frozen=True) -class GitProvenance: - """Which commit of solprop produced a file, and whether it was modified. - - Attributes - ---------- - commit : str - Full 40-character hexadecimal commit hash of the running checkout. - branch : str - Branch name, or ``'HEAD'`` when the checkout is detached. - dirty : bool - Whether the working tree had uncommitted changes to tracked files. A dirty - run is reproducible only up to those changes, which is the point of recording - it. It is a separate field rather than a suffix on the hash string, so the - commit stays parsable. - - Examples - -------- - >>> from solprop.output.schema import GitProvenance - >>> record = GitProvenance(commit="0" * 40, branch="main", dirty=False) - >>> len(record.commit), record.dirty - (40, False) - """ - - commit: str - branch: str - dirty: bool - - @classmethod - def detect(cls) -> "GitProvenance | None": - """Return the provenance of the running solprop checkout, if there is one. - - An installed release is not a checkout, git may be absent, and the package may - sit inside an unrelated repository's worktree -- a virtual environment inside a - user's own project, whose ``git rev-parse`` answers with *that* project's - commit; every one of those returns ``None`` rather than a placeholder string, - so that a caller can tell "no provenance" from "this commit". The test that - separates the last case from a development checkout is whether the repository - found actually tracks this file. - - Returns - ------- - GitProvenance or None - The commit, branch and dirty flag, or ``None`` when they cannot be read. - - Examples - -------- - >>> from solprop.output.schema import GitProvenance - >>> record = GitProvenance.detect() - >>> record is None or len(record.commit) == 40 - True - """ - here = Path(__file__).resolve() - root = here.parent - # A repository was found, but is it solprop's? Inside a virtual environment that - # lives in some other project's checkout, git answers for that project. Only a - # repository that tracks this very file is the one to record; that cannot - # change while the process runs, so it is asked once. - if not _tracks(here): - return None - # One command gives the commit, the branch and the dirty flag: the per-run - # cost of provenance is a single git process (SEP 25 section 6.3). - status = _run_git( - ["status", "--porcelain=v2", "--branch", "--untracked-files=no"], root - ) - if status is None: - return None - headers = dict( - line[2:].split(" ", 1) - for line in status.splitlines() - if line.startswith("# ") and " " in line[2:] - ) - commit = headers.get("branch.oid", "") - if len(commit) != 40: - return None - branch = headers.get("branch.head", "") - dirty = any(line and not line.startswith("#") for line in status.splitlines()) - return cls( - commit=commit, - branch="HEAD" if branch in {"", "(detached)"} else branch, - dirty=dirty, - ) - - -@functools.cache -def _tracks(path: Path) -> bool: - """Return whether the git repository around `path` tracks it, asked once. - - - Returns: ``True`` when git runs, finds a repository and that repository tracks the - file. - """ - return _run_git(["ls-files", "--error-unmatch", path.name], path.parent) is not None - - -def _version_of(name: str) -> str: - """Return an installed distribution's version, or ``'unknown'``. - - - Returns: the version string, or ``'unknown'`` when the distribution is not - installed. - """ - try: - return _package_version(name) - except PackageNotFoundError: # pragma: no cover - every dependency is declared - return "unknown" - - -def package_versions() -> dict[str, str]: - """Return the versions of solprop and of the packages a stored file depends on. - - Recorded so that a file can be read years later with the same numerics in view. - The versions are deliberately *outside* the configuration hash: upgrading NumPy - must not refuse to resume a run. optmat's version is here too, for a reader; a run - that takes a material from optmat carries it again in its material manifest - (:attr:`RunMetadata.materials`), where it is part of the key. - - Returns - ------- - dict - Version strings keyed by ``'solprop'``, ``'numpy'``, ``'scipy'``, ``'h5py'``, - ``'optmat'`` (its full version string) and ``'python'``. - - Examples - -------- - >>> from solprop.output.schema import package_versions - >>> sorted(package_versions()) - ['h5py', 'numpy', 'optmat', 'python', 'scipy', 'solprop'] - """ - return dict(_installed_versions()) - - -@functools.cache -def _installed_versions() -> dict[str, str]: - """Return :func:`package_versions`, read once per process. - - Every record asks, so every scan point did; what is installed does not change - under a running process. - - - Returns: the versions; callers copy it. - """ - return { - "solprop": _version_of("solprop"), - "numpy": _version_of("numpy"), - "scipy": _version_of("scipy"), - "h5py": _version_of("h5py"), - "optmat": _version_of("optmat"), - "python": platform.python_version(), - } - - -# ------------------------------------------------------------------------- records - - -@dataclass(frozen=True, kw_only=True) -class GridRecord: - """The scalars that identify the grid a run was computed on. - - Only scalars: the four axis arrays a :class:`solprop.grid.TimeGrid` holds are - reconstructible from these, and keeping them out of the record keeps the metadata - JSON, and the configuration hash taken over it, small. - - Attributes - ---------- - kind : {'field', 'envelope'} - Spectral layout, which fixes what the stored field means and which conversion - the storage boundary applies. - n_t : int - Coarse time samples. - n_to : int - Fine time samples. - n_omega : int - Coarse spectral bins, the length of the stored field's last axis. - n_omega_fine : int - Fine spectral bins. - dt : float - Coarse sample spacing, s. - dt_fine : float - Fine sample spacing, s. - ffac : float - Oversampling factor, dimensionless. - omega0 : float - Reference angular frequency, rad/s. - d_omega : float - Spectral bin spacing, rad/s. - length : float - Propagation length the grid was built for, m. - reference_wavelength : float - Wavelength of ``omega0``, m. - omega_min : float - Lower in-band angular frequency, rad/s. - omega_max : float - Upper in-band angular frequency, rad/s. - omega_max_window : float - Upper edge of the active band, rad/s. - t_window_min : float - Lower inner edge of the temporal collar, s: the window is flat between this - and ``t_window_max`` and tapers to the ends of the axis outside them. - t_window_max : float - Upper inner edge of the temporal collar, s. - - Examples - -------- - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.output.schema import GridRecord - >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - >>> record = GridRecord.from_grid(grid) - >>> record.kind, record.n_omega == grid.n_omega - ('envelope', True) - """ - - kind: fourier.GridKind - n_t: int - n_to: int - n_omega: int - n_omega_fine: int - dt: float - dt_fine: float - ffac: float - omega0: float - d_omega: float - length: float - reference_wavelength: float - omega_min: float - omega_max: float - omega_max_window: float - t_window_min: float - t_window_max: float - - @classmethod - def from_grid(cls, grid: TimeGrid) -> "GridRecord": - """Return the record of a grid. - - Parameters - ---------- - grid : solprop.grid.TimeGrid - The grid the run used. - - Returns - ------- - GridRecord - Its scalar description. - - Examples - -------- - >>> from solprop.grid import FieldGrid - >>> from solprop.output.schema import GridRecord - >>> grid = FieldGrid.from_limits(1.0, 800e-9, (400e-9, 2e-6), 1e-12) - >>> GridRecord.from_grid(grid).kind - 'field' - """ - return cls( - kind=grid.kind, - n_t=grid.n_t, - n_to=grid.n_to, - n_omega=grid.n_omega, - n_omega_fine=grid.n_omega_fine, - dt=grid.dt, - dt_fine=grid.dt_fine, - ffac=grid.ffac, - omega0=grid.omega0, - d_omega=grid.d_omega, - length=grid.length, - reference_wavelength=grid.reference_wavelength, - omega_min=grid.omega_min, - omega_max=grid.omega_max, - omega_max_window=grid.omega_max_window, - t_window_min=grid.t_collar.one_low, - t_window_max=grid.t_collar.one_high, - ) - - -@dataclass(frozen=True, kw_only=True) -class ModeRecord: - """One propagated mode, by class, effective area, channel label and configuration. - - Attributes - ---------- - kind : str - Qualified class name of the mode, ``'module.ClassName'``. - effective_area : float - ``A_eff`` in m^2 at the reference position, the one number processing needs - most often and the one a plot label carries. - configuration : dict - The mode object as :func:`serialise` records it. - label : collections.abc.Mapping or None - The physical channel this row of the mode axis is, as - :meth:`solprop.modes.protocol.ChannelLabel.as_mapping` writes it: the - family, the azimuthal and radial orders, the parity, the handedness and - the display label. :meth:`from_mode` always records one; ``None`` on a - record built without it, which names its channel only by class name and - position, and which :func:`solprop.processing.modal.channel_labels` - refuses. Part of :meth:`RunMetadata.hashed`. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.output.schema import ModeRecord - >>> record = ModeRecord.from_mode(MarcatiliMode(125e-6, "He", 1.0)) - >>> record.kind - 'solprop.modes.capillary.MarcatiliMode' - >>> None if record.label is None else record.label["display_label"] - 'HE1,1:even' - """ - - kind: str - effective_area: float - configuration: Mapping[str, JsonValue] - label: Mapping[str, JsonValue] | None = None - - @classmethod - def from_mode(cls, mode: Mode, z: float = 0.0) -> "ModeRecord": - """Return the record of a mode. - - Parameters - ---------- - mode : solprop.modes.protocol.Mode - The mode. - z : float, optional - Position along the waveguide the effective area is read at, m. - - Returns - ------- - ModeRecord - Its description. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.output.schema import ModeRecord - >>> record = ModeRecord.from_mode(MarcatiliMode(75e-6, "He", 1.0)) - >>> f"{record.effective_area:.6e}" - '8.421575e-09' - """ - kind = type(mode) - # serialise returns a dictionary for everything that is not a primitive, a - # sequence or a callable, and a mode is none of those, so the cast is total. - configuration = cast("dict[str, JsonValue]", serialise(mode)) - if getattr(mode, "mixture", None) is not None: - # These legacy constructor slots are placeholders when a mixture owns - # every species model and density path; they would imply a second fill. - for name in ("gas", "pressure", "temperature", "source", "susceptibility"): - configuration.pop(name, None) - return cls( - kind=f"{kind.__module__}.{kind.__qualname__}", - effective_area=mode.aeff(z), - configuration=configuration, - label=dict(mode.channel_label.as_mapping()), - ) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class NoiseRecord: - """The shot-noise realisation a run started from, and the seed that drew it. - - Equality is by identity (``eq=False``) because ``spectrum`` is an array; compare - the seeds, which is what reproducibility means. - - The spectrum is stored in the **internal** FFT convention, unlike the field: it is - not a user-facing spectrum but the object the stepper adds at every stage, and - conjugating it would make it disagree with the file it has to be read back into. - :attr:`spectral_convention` says so explicitly. - - Attributes - ---------- - model : {'input', 'modified'} - Which noise model the realisation belongs to. - seed : int or None - Entropy of the generator, or ``None`` when it could not be recovered. - spectrum : numpy.ndarray - The realisation, shape ``(n_modes, n_pol, n_omega)``, complex, in the square - root of watts. - spectral_convention : str - Always ``'fft'``. - - Examples - -------- - >>> import numpy as np - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.noise import input_noise - >>> from solprop.output.schema import NoiseRecord - >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - >>> realisation = input_noise(grid, np.random.default_rng(3)) - >>> record = NoiseRecord.from_realisation(realisation) - >>> record.seed, record.spectral_convention - (3, 'fft') - """ - - model: str - seed: int | None - spectrum: Complex[np.ndarray, "n_modes n_pol n_omega"] - spectral_convention: str = INTERNAL_CONVENTION - - def __post_init__(self) -> None: - """Adopt a read-only copy of the spectrum, so a record cannot drift.""" - frozen = np.array(self.spectrum, dtype=np.complex128, copy=True) - frozen.setflags(write=False) - object.__setattr__(self, "spectrum", frozen) - - @classmethod - def from_realisation(cls, realisation: NoiseRealisation) -> "NoiseRecord": - """Return the record of a drawn realisation. - - Parameters - ---------- - realisation : solprop.noise.NoiseRealisation - The realisation the run used. - - Returns - ------- - NoiseRecord - Its description. - - Examples - -------- - >>> import numpy as np - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.noise import modified_noise - >>> from solprop.output.schema import NoiseRecord - >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - >>> NoiseRecord.from_realisation(modified_noise(grid, np.random.default_rng(1))) - ... # doctest: +ELLIPSIS - NoiseRecord(model='modified', seed=1, ...) - """ - return cls( - model=realisation.model, - seed=realisation.seed, - spectrum=realisation.spectrum, - ) - - -@dataclass(frozen=True) -class NoiseResolution: - """How a run's noise model was chosen: what was asked for, what it carries, and why. - - Attributes - ---------- - requested : {'default', 'input', 'modified'} or None - The model the caller asked for: ``"default"`` when the keyword was omitted, - ``None`` when noise was switched off explicitly. - resolved : {'input', 'modified'} or None - The model the run carries, or ``None`` for a noiseless run. - reason : str or None - Why a default resolved to no noise, for example that the hybrid planar grid - declares no photon measure; ``None`` otherwise. - - Examples - -------- - >>> from solprop.output.schema import NoiseResolution - >>> NoiseResolution("default", "input") - NoiseResolution(requested='default', resolved='input', reason=None) - """ - - requested: str | None - resolved: str | None - reason: str | None = None - - -@dataclass(frozen=True) -class DefaultsResolution: - """Which of a run's numerics took its route's default, and which route that was. - - The companion of :class:`NoiseResolution` for the stepping (``docs/guide/numerics/ - choosing-a-stepper.md``): the integrator, the step controller and the temporal - collar's scheme are each either the caller's or the route's default from - :data:`solprop.driver.defaults.ROUTE_DEFAULTS`. Outside the hash: what the run - computes is in the stepper record already, and how it was chosen is not physics. - - Attributes - ---------- - route : str or None - The row of the defaults table the constructor read, or ``None`` for settings - built by hand. - defaulted : sequence of str - Which of ``"integrator"``, ``"rtol"`` and ``"absorber_scheme"`` took the - route's default, in that order, adopted as a tuple. - - Examples - -------- - >>> from solprop.output.schema import DefaultsResolution - >>> DefaultsResolution("capillary", ["integrator", "rtol"]) - DefaultsResolution(route='capillary', defaulted=('integrator', 'rtol')) - """ - - route: str | None - defaulted: Sequence[str] = () - - def __post_init__(self) -> None: - """Adopt the names as a tuple, so a record read back compares equal.""" - object.__setattr__(self, "defaulted", tuple(self.defaulted)) - - -@dataclass(frozen=True) -class StepLimits: - """The caps a run put on the controller's step proposal. - - Grouped into one record because they travel together and because a constructor - that took them one by one would need more arguments than the project allows. - - Attributes - ---------- - dz_min : float - Smallest step the controller was allowed to propose, m. Zero when the driver - left it to :func:`solprop.stepper.controller.default_dz_min`. - dz_max : float - Largest step, m; infinite when uncapped. - dz_init : float - First step, m; zero when the driver chose it. - stretch : float - Landing stretch factor, dimensionless; 1 switches landing stretch off. - interval_cap : bool - Whether a step was capped at its operator-table interval's width. - auto_init : bool - Whether the start-up rule took the step after the first from the first step's - estimate. - interpolate_planes : bool - Whether the save planes but the end and the hard kinks were interpolated - inside accepted steps; ``False`` when every plane was landed on. - step_planes : int - A positive ``k`` when the end of every ``k``-th accepted step was saved too; - ``0`` for the requested planes only. - - Examples - -------- - >>> from solprop.output.schema import StepLimits - >>> StepLimits(dz_max=0.01).dz_max - 0.01 - """ - - dz_min: float = 0.0 - dz_max: float = math.inf - dz_init: float = 0.0 - stretch: float = 1.1 - interval_cap: bool = True - auto_init: bool = False - interpolate_planes: bool = False - step_planes: int = 0 - - -@dataclass(frozen=True, eq=False, kw_only=True) -class StepperRecord: - """The solver settings a run used, including the schedule it actually took. - - Equality is by identity (``eq=False``) because ``schedule`` is an array. Schedules - are first class and re-loadable (see ``docs/dev/decisions.md``): a replayed run is - bit-identical, and every ensemble member then carries the same numerical error as a - common systematic. - - Build one from the objects a run held with :meth:`from_settings`, and from a stored - document with :meth:`from_record`. - - Attributes - ---------- - tableau : str - Name of the Runge-Kutta scheme. - method : str or None - The integrator that stepped the run instead of the tableau, as its ``repr``: - a low-call Adams method (SEP 13) or the low-storage march (SEP 24); ``None`` - for the Runge-Kutta march. - controller : dict - The step controller as :func:`serialise` records it: tolerances, safety and - gain factors, lag and the norm weights. - limits : StepLimits - The caps on the controller's proposal. - schedule : numpy.ndarray or None - Step lengths of shape ``(n_steps,)``, m, when a fixed schedule was replayed or - recorded; ``None`` for a purely adaptive run. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StepLimits, StepperRecord - >>> from solprop.stepper import DOPRI5, FixedSchedule, PIController - >>> record = StepperRecord.from_settings( - ... DOPRI5, - ... PIController(rtol=1e-8, atol=0.0), - ... schedule=FixedSchedule(np.array([0.5, 0.5])), - ... limits=StepLimits(dz_max=0.1), - ... ) - >>> record.tableau, record.limits.dz_max - ('DOPRI5', 0.1) - """ - - tableau: str - controller: Mapping[str, JsonValue] - limits: StepLimits = dataclasses.field(default_factory=StepLimits) - schedule: Float[np.ndarray, " n_steps"] | None = None - method: str | None = None - - def __post_init__(self) -> None: - """Adopt a read-only copy of the schedule, so a record cannot drift.""" - if self.schedule is not None: - frozen = np.array(self.schedule, dtype=np.float64, copy=True) - frozen.setflags(write=False) - object.__setattr__(self, "schedule", frozen) - - @classmethod - def from_settings( - cls, - tableau: Tableau, - controller: PIController, - *, - schedule: FixedSchedule | None = None, - limits: StepLimits | None = None, - method: str | None = None, - ) -> "StepperRecord": - """Return the record of the objects a run stepped with. - - Parameters - ---------- - tableau : solprop.stepper.tableau.Tableau - The scheme. - controller : solprop.stepper.controller.PIController - The step controller, which carries the tolerances and the norm choice. - schedule : solprop.stepper.schedule.FixedSchedule, optional - The fixed schedule replayed, if any. - limits : StepLimits, optional - The caps on the controller's proposal; the defaults when omitted. - method : str or None, optional - The Adams method that stepped the run instead of the tableau, as its - ``repr``; ``None`` for the Runge-Kutta march. - - Returns - ------- - StepperRecord - The record. - - Examples - -------- - >>> from solprop.output.schema import StepperRecord - >>> from solprop.stepper import TSIT5, PIController - >>> record = StepperRecord.from_settings( - ... TSIT5, PIController(rtol=1e-6, atol=0.0) - ... ) - >>> record.tableau, record.schedule is None - ('Tsit5', True) - """ - # A PIController is a dataclass, so serialise always returns a dictionary. - record = cast("dict[str, JsonValue]", serialise(controller)) - return cls( - tableau=tableau.name, - controller=record, - limits=StepLimits() if limits is None else limits, - schedule=None if schedule is None else schedule.steps, - method=method, - ) - - @classmethod - def from_record(cls, document: Mapping[str, JsonValue]) -> "StepperRecord": - """Return the record a stored document describes. - - Parameters - ---------- - document : collections.abc.Mapping - The form :func:`serialise` produced from a record. - - Returns - ------- - StepperRecord - The record. - - Examples - -------- - >>> from solprop.output.schema import StepperRecord, serialise - >>> from solprop.stepper import TSIT5, PIController - >>> one = StepperRecord.from_settings(TSIT5, PIController(rtol=1e-6, atol=0.0)) - >>> StepperRecord.from_record(serialise(one)).tableau - 'Tsit5' - """ - # Another build of this schema may lack an optional field. - stored = cast( - "Callable[..., Any]", - functools.partial(_stored_entry, document, path=("stepper",)), - ) - - schedule = _decode(stored("schedule")) - limits = stored("limits") - return cls( - tableau=str(stored("tableau", required=True)), - controller=cast( - "dict[str, JsonValue]", stored("controller", required=True) - ), - limits=StepLimits() - if limits is None - else _rebuild( - StepLimits, cast("dict[str, JsonValue]", limits), ("stepper", "limits") - ), - schedule=None if schedule is None else np.asarray(schedule, dtype=float), - method=_optional_text(stored("method")), - ) - - -HISTORY_FIELDS: Mapping[str, type] = { - "z": np.float64, - "h": np.float64, - "norm": np.float64, - "accepted": np.bool_, - "wall_time": np.float64, -} -"""Dtype of each :class:`StepHistory` field, in the order they are laid out.""" - - -@dataclass(frozen=True, eq=False, kw_only=True) -class StepHistory(Displayed): - """What the stepper did between two saved planes. - - Recorded whether the run was adaptive or replayed a schedule, because the embedded - error estimate costs nothing extra and a fixed run still needs it: a largest scaled - norm above one means a step was too coarse for the tolerance the norm is scaled by - (``docs/dev/stepper.md`` §8). - - Equality is by identity (``eq=False``) because every field is an array. - - Attributes - ---------- - z : numpy.ndarray - Position at the start of each attempt, shape ``(n_steps,)``, m. - h : numpy.ndarray - Length of each attempt, shape ``(n_steps,)``, m. - norm : numpy.ndarray - Scaled error norm each attempt scored as executed, shape ``(n_steps,)``, - dimensionless; the controller's rescaled equivalent of a landing step is its - own and is not recorded. The low-storage march estimates none and records 0.0, - "not estimated", never a step that met its tolerance exactly. - accepted : numpy.ndarray - Whether each attempt was accepted, shape ``(n_steps,)``, boolean. - wall_time : numpy.ndarray - Wall-clock time each attempt took, shape ``(n_steps,)``, s. - - Raises - ------ - OutputError - If the five arrays do not have equal shapes. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StepHistory - >>> history = StepHistory( - ... z=np.array([0.0, 0.5]), - ... h=np.array([0.5, 0.5]), - ... norm=np.array([0.3, 0.4]), - ... accepted=np.array([True, True]), - ... wall_time=np.array([1e-3, 1e-3]), - ... ) - >>> history.n_steps, history.n_rejected - (2, 0) - """ - - # Independent axis names on purpose: their agreement is checked below with an - # OutputError naming the culprit, and that check must also hold in production, - # where the test session's runtime type checker is absent. One shared name would - # let the checker forestall it. - z: Float[np.ndarray, " n_history_z"] - h: Float[np.ndarray, " n_history_h"] - norm: Float[np.ndarray, " n_history_norm"] - accepted: Bool[np.ndarray, " n_history_accepted"] - wall_time: Float[np.ndarray, " n_history_wall"] - - def __repr__(self) -> str: - """Return a one-line description: attempts, rejections and wall time. - - Returns - ------- - str - For example ``StepHistory(1842 attempts, 12 rejected, 12.2 s)``. - """ - attempts = int(np.asarray(self.z).size) - rejected = attempts - int(np.count_nonzero(self.accepted)) - seconds = float(np.sum(self.wall_time)) if attempts else 0.0 - return line( - "StepHistory", - [ - ("", f"{attempts} attempts"), - ("", f"{rejected} rejected"), - ("", engineering(seconds, "s")), - ], - ) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - attempts = int(np.asarray(self.z).size) - if not attempts: - return "StepHistory: no step attempted", [] - accepted = np.asarray(self.accepted, dtype=bool) - steps = np.asarray(self.h, dtype=float)[accepted] - return "StepHistory", [ - ( - "attempts", - f"{attempts}, of which {attempts - int(accepted.sum())} rejected", - ), - ("accepted steps", summarise(steps, "m").replace("values", "steps")), - ("largest error norm", f"{float(np.max(self.norm)):.3g}"), - ("wall time", engineering(float(np.sum(self.wall_time)), "s")), - ] - - def __post_init__(self) -> None: - """Check that the five arrays describe one sequence, and freeze them. - - Raises - ------ - OutputError - If the five shapes disagree. That they are one-dimensional is fixed by the - annotations, as it is for :class:`solprop.stepper.tableau.Tableau`. - """ - shapes = {} - for name, dtype in HISTORY_FIELDS.items(): - values = np.array(getattr(self, name), dtype=dtype, copy=True) - values.setflags(write=False) - object.__setattr__(self, name, values) - shapes[name] = values.shape - if len(set(shapes.values())) > 1: - msg = f"step history arrays must have equal shapes, got {shapes}" - raise OutputError(msg) - - @classmethod - def empty(cls) -> "StepHistory": - """Return the history of a plane no step led to, such as the starting plane. - - Returns - ------- - StepHistory - Five empty arrays. - - Examples - -------- - >>> from solprop.output.schema import StepHistory - >>> StepHistory.empty().n_steps - 0 - """ - return cls( - z=np.empty(0), - h=np.empty(0), - norm=np.empty(0), - accepted=np.empty(0, dtype=bool), - wall_time=np.empty(0), - ) - - @property - def n_steps(self) -> int: # numpydoc ignore=RT01 - """Number of attempts recorded. - - Accepted and rejected attempts together. - """ - return int(self.z.size) - - @property - def n_rejected(self) -> int: # numpydoc ignore=RT01 - """Number of rejected attempts.""" - return int(np.count_nonzero(~self.accepted)) - - -STEP_STATS_INDEX: Mapping[str, type] = {"z": np.float64, "step": np.int64} -"""Dtype of each :class:`StepStats` index column, beside the statistics themselves.""" - - -@dataclass(frozen=True, eq=False, kw_only=True) -class StepStats(Displayed): - """The statistics a backend evaluated *between* two saved planes. - - A run whose :class:`solprop.stats.StatsSpec` names a ``periodic`` subset gets one - row every ``period`` accepted steps, evaluated inside the backend's step loop and - flushed here at each plane. Empty for a run that asked for none, which is - the default. - - Stored the way the step history is -- flat columns concatenated over planes with a - per-plane ``counts`` beside them -- so that a resume appends and a scan needs no NaN - padding, which growing datasets would force on it. - - Equality is by identity (``eq=False``) because every field is an array. - - Attributes - ---------- - z : numpy.ndarray - Position of each row, m, float64, shape ``(n_rows,)``. - step : numpy.ndarray - Index of the accepted step each row was taken after, into this plane's - :class:`StepHistory`, int64, shape ``(n_rows,)``. The history counts rejected - attempts, so this is not ``row * period``. - values : dict - The statistics, keyed by catalogue name, each shaped ``(n_rows, *shape)`` with - the statistic's own shape after the row axis, as the backend's rows are. - - Raises - ------ - OutputError - If the columns do not all have the same number of rows. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StepStats - >>> rows = StepStats(z=np.array([0.1, 0.2]), step=np.array([0, 2]), - ... values={"energy": np.ones((2, 1, 1))}) - >>> rows.n_rows, rows.names - (2, ('energy',)) - """ - - z: Float[np.ndarray, " n_rows_z"] - step: Int[np.ndarray, " n_rows_step"] - values: Mapping[str, np.ndarray] = dataclasses.field(default_factory=dict) - - def __repr__(self) -> str: - """Return a one-line description: how many rows of which statistics. - - Returns - ------- - str - For example ``StepStats(1842 rows of energy, peak_power)``. - """ - rows = int(np.asarray(self.z).size) - return f"StepStats({rows} rows of {', '.join(self.values) or 'nothing'})" - - def __post_init__(self) -> None: - """Freeze the columns and check that they describe the same rows. - - Raises - ------ - OutputError - Naming the columns whose row counts disagree. - """ - shapes = {} - for name, dtype in STEP_STATS_INDEX.items(): - values = np.array(getattr(self, name), dtype=dtype, copy=True) - values.setflags(write=False) - object.__setattr__(self, name, values) - shapes[name] = values.shape - adopted = {} - for name, value in self.values.items(): - rows = np.array(value, copy=True) - rows.setflags(write=False) - adopted[name] = rows - shapes[name] = rows.shape[:1] - object.__setattr__(self, "values", adopted) - if len(set(shapes.values())) > 1: - msg = f"step statistics columns must have equal row counts, got {shapes}" - raise OutputError(msg) - - @classmethod - def empty(cls) -> "StepStats": - """Return the channel a run without a periodic subset carries. - - Returns - ------- - StepStats - No rows and no statistics. - """ - return cls(z=np.empty(0), step=np.empty(0, dtype=np.int64), values={}) - - @property - def n_rows(self) -> int: # numpydoc ignore=RT01 - """How many rows belong to this plane.""" - return int(self.z.size) - - @property - def names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The statistics the channel carries, sorted.""" - return tuple(sorted(self.values)) - - -_ADOPTED: weakref.WeakValueDictionary[int, np.ndarray] = weakref.WeakValueDictionary() -"""The arrays :func:`_adopted` marked, by id, for as long as each lives.""" - - -def _adopted(values: np.ndarray) -> np.ndarray: - """Mark an array a :class:`SavedPlane` takes as it is, instead of copying it. - - The driver's route (:func:`solprop.driver.run.run_problem`) for what an observation - holds. Only for an array that nothing writes to again, as - :meth:`solprop.backends.protocol.HostStepping.observe` promises, and never for a - batch member, whose view would pin the whole batch. - - - Returns: the same array, read-only. - """ - values.setflags(write=False) - _ADOPTED[id(values)] = values - return values - - -def _plane_array( - values: np.ndarray, dtype: type[np.complex128] | type[np.float64] -) -> np.ndarray: - """Return an array as a :class:`SavedPlane` holds it: read-only, nobody else's. - - - Returns: `values` itself when :func:`_adopted` marked it, a frozen copy otherwise. - """ - if _ADOPTED.get(id(values)) is values: - return values - held = np.array(values, dtype=dtype, copy=True) - held.setflags(write=False) - return held - - -@dataclass(frozen=True, eq=False, kw_only=True) -class SavedPlane(Displayed): - """One saved position: the field there, its statistics and how it was reached. - - The field is in the **internal** FFT convention, exactly as a backend produces it; - a handler converts it on the way to storage. Equality is by identity - (``eq=False``) because the field is an array. A **field-free** plane, of a run - that keeps no field there (:class:`FieldRetention`), carries ``field=None`` and no - noise phase: its position, statistics and step history only. - - Attributes - ---------- - z : float - Position along the waveguide, m. - field : numpy.ndarray or None - Stored spectrum, shape ``(n_modes, n_pol, n_omega)``, complex, in the square - root of watts, internal convention; ``None`` on a field-free plane. - stats : dict - Statistics evaluated at this plane, keyed by catalogue name; the values are the - arrays :func:`solprop.stats.collect` returns. - step_history : StepHistory - Every attempt the stepper made since the previous plane. - step_stats : StepStats - The rows a periodic statistics subset produced since the previous plane, empty - unless the run asked for one. - noise_phase : numpy.ndarray or None - Accumulated unitary phase of the noise field at this plane, shape - ``(n_modes, n_pol, n_omega)``, rad, internal convention, so that processing can - restore the background; ``None`` when no noise model is active, and on a - field-free plane. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import SavedPlane, StepHistory - >>> plane = SavedPlane( - ... z=0.0, - ... field=np.ones((1, 1, 5), dtype=complex), - ... stats={"energy": np.ones((1, 1))}, - ... step_history=StepHistory.empty(), - ... ) - >>> plane.shape - (1, 1, 5) - """ - - z: float - field: Complex[np.ndarray, "n_modes n_pol n_omega"] | None - stats: Mapping[str, np.ndarray] = dataclasses.field(default_factory=dict) - step_history: StepHistory = dataclasses.field(default_factory=StepHistory.empty) - step_stats: StepStats = dataclasses.field(default_factory=StepStats.empty) - noise_phase: Float[np.ndarray, "n_modes_phase n_pol_phase n_omega_phase"] | None = ( - None - ) - - def __repr__(self) -> str: - """Return a one-line description: the position and the field's shape. - - Returns - ------- - str - For example ``SavedPlane(z=1.5 m, <2x1x8192 complex>, 14 statistics)``. - """ - return line( - "SavedPlane", - [ - ("z", engineering(self.z, "m")), - ("", self._field_text()), - ("", f"{len(self.stats)} statistics"), - ], - ) - - def _field_text(self) -> str: - """Return the field summarised, or a note that the plane keeps none. - - - Returns: the field's shape, or ``"no field"``. - """ - return "no field" if self.field is None else summarise(self.field) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - return f"SavedPlane at z = {engineering(self.z, 'm')}", [ - ("field", self._field_text()), - ("statistics", ", ".join(self.stats) or "none"), - ("steps since last", repr(self.step_history)), - ("between planes", repr(self.step_stats)), - ] - - def __post_init__(self) -> None: - """Freeze the arrays and check that a noise phase matches the field. - - Raises - ------ - OutputError - If ``z`` is not finite, ``noise_phase`` does not have the field's shape, or - a field-free plane carries a noise phase. - """ - if not math.isfinite(self.z): - msg = f"a saved plane needs a finite position, got {self.z!r}" - raise OutputError(msg) - object.__setattr__(self, "stats", dict(self.stats)) - if self.field is None: - if self.noise_phase is not None: - msg = "a field-free plane carries no noise phase" - raise OutputError(msg) - return - field = _plane_array(self.field, np.complex128) - object.__setattr__(self, "field", field) - if self.noise_phase is not None: - phase = _plane_array(self.noise_phase, np.float64) - if phase.shape != field.shape: - msg = ( - f"noise_phase has shape {phase.shape}, expected the field's " - f"{field.shape}" - ) - raise OutputError(msg) - object.__setattr__(self, "noise_phase", phase) - - @property - def shape(self) -> tuple[int, ...] | None: # numpydoc ignore=RT01 - """The field's shape, or ``None`` on a field-free plane. - - ``(n_modes, n_pol, n_omega)``. - """ - return None if self.field is None else self.field.shape - - -@dataclass(frozen=True, eq=False) -class StoredField: - """A kept plane's field, exported straight into a plane store's stored form. - - What a run's service hands :meth:`PlaneStore.save_exported` beside a plane that - carries no field of its own (SEP 47 section 5.4): the field as the store keeps it, - and the noise phase that a plane with its field would carry. - - Attributes - ---------- - values : numpy.ndarray - The field in the stored form, ``(n_modes, n_pol, n_omega)``: true-power - amplitudes (or a spatial route's field measure), sqrt(W), in the optics - convention, as :meth:`PlaneStore.save` writes a plane's field. - noise_phase : numpy.ndarray or None - The noise field's accumulated phase at the plane, rad, internal convention, the - shape of `values`; ``None`` without a noise model. - - Raises - ------ - OutputError - If `noise_phase` does not have the shape of `values`. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StoredField - >>> StoredField(np.ones((1, 1, 4), complex)).values.shape - (1, 1, 4) - """ - - values: Complex[np.ndarray, "n_modes n_pol n_omega"] - noise_phase: Float[np.ndarray, "n_modes_phase n_pol_phase n_omega_phase"] | None = ( - None - ) - - def __post_init__(self) -> None: - """Check that a noise phase has the field's shape.""" - if self.noise_phase is not None and self.noise_phase.shape != self.values.shape: - msg = ( - f"noise_phase has shape {self.noise_phase.shape}, expected the field's " - f"{self.values.shape}" - ) - raise OutputError(msg) - - -def _with_phase(plane: SavedPlane, phase: np.ndarray) -> SavedPlane: - """Return a field-free plane carrying an exported field's noise phase. - - The store's own view of a plane whose field it holds apart, in the stored form - (:class:`StoredField`); it never leaves the store, whose hooks read the noise - phase from the plane as from one that carries its field. - """ - held = dataclasses.replace(plane) - object.__setattr__(held, "noise_phase", _plane_array(phase, np.float64)) - return held - - -@dataclass(frozen=True) -class RunOutcome: - """Record completion and cumulative attempted work separately from run identity. - - Attributes - ---------- - status : {'running', 'complete', 'budget_exhausted', 'failed'} - Current execution status. A budget stop is explicitly unfinished. - z : float - Last accepted or saved position, m. - message : str - Explanation of a stop or failure; empty on ordinary progress. - logical_calls : collections.abc.Mapping - Cumulative logical response ledger, including startup and failed attempts. - atomic_calls : collections.abc.Mapping - Cumulative provider ledger; empty when no atomic service is bound. - execution : collections.abc.Mapping - Current backend and provider facts, captured before resources close. Kept - separately from the original run metadata so resumes retain their actual - device, plan and diagnostic provenance without changing scientific identity. - previous_executions : tuple of collections.abc.Mapping - Closing records of earlier executions of the same resumed run, in order. - """ - - status: Literal["running", "complete", "budget_exhausted", "failed"] - z: float - message: str = "" - logical_calls: Mapping[str, object] = dataclasses.field(default_factory=dict) - atomic_calls: Mapping[str, object] = dataclasses.field(default_factory=dict) - execution: Mapping[str, object] = dataclasses.field(default_factory=dict) - previous_executions: tuple[Mapping[str, object], ...] = () - - def __post_init__(self) -> None: - """Reject ambiguous status and nonfinite positions, and adopt ledger copies.""" - if self.status not in ("running", "complete", "budget_exhausted", "failed"): - raise OutputError(f"unknown run outcome {self.status!r}") - if not np.isfinite(self.z) or self.z < 0: - raise OutputError("run outcome z must be finite and nonnegative") - # One JSON round trip validates and copies every part: the record is built - # at every saved plane, and one per part and per amended copy, nine a plane, - # were about 5 % of a short capillary run on MemoryOutput. - parts: dict[str, object] = { - "logical_calls": dict(self.logical_calls), - "atomic_calls": dict(self.atomic_calls), - "execution": dict(self.execution), - "previous_executions": [ - dict(record) for record in self.previous_executions - ], - } - try: - owned = _outcome_record(parts) - except OutputError as error: - # Only now is each part checked alone, so that the error names the one to - # look at: the whole record fails exactly when one of its parts does. - name, why = next( - (name, why) - for name, part in parts.items() - if (why := _json_failure(part)) is not None - ) - msg = ( - "execution records must contain finite JSON values: the run " - f"outcome's {name} holds {why}" - ) - raise OutputError(msg) from error - for name in ("logical_calls", "atomic_calls", "execution"): - object.__setattr__(self, name, owned[name]) - object.__setattr__( - self, - "previous_executions", - tuple(cast("list[dict[str, object]]", owned["previous_executions"])), - ) - - -def _json_failure(part: object) -> str | None: - """Return why a part of a run outcome is not finite JSON, or ``None`` if it is.""" - try: - json.dumps(part, allow_nan=False) - except (TypeError, ValueError) as error: - return str(error) - return None - - -def _json_copy(value: object) -> object: - """Return an independent copy of a value made of validated JSON values. - - Cheaper than another JSON round trip, and than :func:`copy.deepcopy`, because the - value is known to hold only dicts, lists, strings, numbers, booleans and - ``None``. - """ - if isinstance(value, dict): - return {key: _json_copy(item) for key, item in value.items()} - if isinstance(value, list): - return [_json_copy(item) for item in value] - return value - - -def _amended_outcome( - outcome: RunOutcome, - *, - previous_executions: tuple[Mapping[str, object], ...], -) -> RunOutcome: - """Return a copy of an outcome with closing records a store has already validated. - - :func:`dataclasses.replace` would validate the whole record again; the store's - earlier closing records were validated when it took them, so a saved plane validates - its record once. The caller hands over copies of them (:func:`_json_copy`), so that - each outcome holds its own and a change to one read back reaches no other. - """ - amended = copy.copy(outcome) - object.__setattr__(amended, "previous_executions", previous_executions) - return amended - - -def _outcome_record(record: Mapping[str, object]) -> dict[str, object]: - """Own finite JSON values so later provider mutations cannot change a snapshot. - - - ``record``: execution facts composed of finite JSON values. - """ - try: - return cast( - "dict[str, object]", json.loads(json.dumps(dict(record), allow_nan=False)) - ) - except (TypeError, ValueError) as error: - raise OutputError( - f"execution records must contain finite JSON values: {error}" - ) from error - - -@dataclass(frozen=True, eq=False) -class TerminalCheckpoint: - """Keep an accepted field between scheduled saves without adding an observation. - - Attributes - ---------- - plane : SavedPlane - Accepted field in the internal FFT convention and the output amplitude - convention, with already available history and periodic statistics. - Creating a checkpoint never requests fresh material statistics. It always - carries the field, also in a run that keeps none at its planes, so that a - stopped run resumes (SEP 22, Q1). - next_save_index : int - Index of the first scheduled save strictly beyond this field. - landed : bool - Whether the field is an accepted endpoint rather than a dense observation. - """ - - plane: SavedPlane - next_save_index: int - landed: bool = True - - def __post_init__(self) -> None: - """Reject an unusable save index, a field-free or a nonfinite checkpoint.""" - if type(self.next_save_index) is not int or self.next_save_index < 0: - raise OutputError( - "checkpoint next_save_index must be a nonnegative integer" - ) - if self.plane.field is None: - raise OutputError( - "a terminal checkpoint carries the field, also in a run that keeps " - "none at its planes" - ) - if ( - not np.isfinite(self.plane.z) - or self.plane.z < 0 - or not np.all(np.isfinite(self.plane.field)) - ): - raise OutputError("checkpoint position and field must be finite") - - -@dataclass(frozen=True, eq=False) -class MarchSnapshot: # numpydoc ignore=PR02 - """An Adams march's state at an accepted position, for a resume to continue. - - What a resume needs to carry on as the uninterrupted run would: the accepted state - exactly as the backend held it, the history's samples in their engine rows, which - sample each history entry is, the order, the rejection count and the step the - controller proposed next. A snapshot is never the record of what was computed, only - of where a march stood: a resume continues it when its history key matches the - resuming method's, and otherwise starts the history afresh. - - Equality is by identity (``eq=False``) because the fields hold arrays. - - Attributes - ---------- - z : float - The accepted position, m. - state : numpy.ndarray - The accepted state, shape ``(n_modes, n_pol, n_omega)``, in the internal FFT - convention, exactly as the backend held it. A file stores it with a leading - axis of one, the layout it has always had (SEP 47 section 6.7). - proposal : float - The step the controller proposed next, m. - method : str - The method's representation, kept as provenance. - order : int - The order chosen for the next step. - rejections : int - Consecutive rejections so far. - last_rejected : bool - Whether the last trial was rejected. - positions : numpy.ndarray - Each history entry's position, m, oldest first. - identities : tuple of str - Each entry's kind (``"accepted"`` or ``"predicted"``). - slots : numpy.ndarray - Each entry's engine row. - rows : collections.abc.Mapping - The engine's rows by name: the samples (``"store"``) and, for a compensated - exponential march, the sampled states and rates and each sample's flow - weights, in the engine's own layout, which keeps a leading axis of one after - the row axis. - collar_was_quiet : bool - Whether the in-stage collar was left out, quiet, at the last attempt, which - holds the first step after it returns to its stable step (SEP 39 section - 6.5.9). - history_key : str or None - What defines the history - (:attr:`solprop.stepper.multistep.Multistep.history_key`), which the resuming - method's must equal; ``None`` for a snapshot that names none, which starts the - history afresh. - - Raises - ------ - OutputError - If the entries disagree in number, or a position or the state is not finite. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import MarchSnapshot - >>> snapshot = MarchSnapshot( - ... z=0.5, state=np.ones((1, 1, 4), complex), proposal=0.1, method="M()", - ... order=2, rejections=0, last_rejected=False, - ... positions=np.array([0.4, 0.5]), identities=("accepted", "predicted"), - ... slots=np.array([0, 1]), rows={"store": np.zeros((4, 1, 1, 1, 4), complex)}, - ... ) - >>> snapshot.nbytes - 320 - """ - - z: float - state: Complex[np.ndarray, "n_modes n_pol n_omega"] - proposal: float - method: str - order: int - rejections: int - last_rejected: bool - positions: Float[np.ndarray, " n_entries"] - identities: tuple[str, ...] - slots: Int[np.ndarray, " n_entries"] - rows: Mapping[str, np.ndarray] - collar_was_quiet: bool = False - history_key: str | None = None - - def __post_init__(self) -> None: - """Refuse a snapshot whose entries disagree or whose numbers are not finite. - - Raises - ------ - OutputError - Naming the inconsistency. - """ - count = len(self.identities) - if self.positions.shape != (count,) or self.slots.shape != (count,): - raise OutputError("march snapshot entries disagree in number") - if not ( - math.isfinite(self.z) - and math.isfinite(self.proposal) - and np.all(np.isfinite(self.positions)) - and np.all(np.isfinite(self.state)) - ): - raise OutputError("march snapshot positions and state must be finite") - - @property - def nbytes(self) -> int: # numpydoc ignore=RT01 - """The bytes the snapshot's arrays hold. - - The state and every engine row. - """ - return int(self.state.nbytes + sum(row.nbytes for row in self.rows.values())) - - -def _holds_carried(plane: SavedPlane, carried: SavedPlane) -> bool: - """Return whether a checkpoint already begins with the attempts a resume carried. - - The checkpoint a store holds after a resume, or a copy of it, starts with the - carried attempts, every column equal; a new one from the resumed march starts - where the resume did, with attempts of its own and its own wall clock. - - - Returns: True when `plane`'s history and rows start with `carried`'s, or `carried` - holds nothing to add. - """ - before, history = carried.step_history, plane.step_history - if before.n_steps == 0: - return carried.step_stats.n_rows == 0 - if history.n_steps < before.n_steps: - return False - return all( - np.array_equal(getattr(history, name)[: before.n_steps], getattr(before, name)) - for name in HISTORY_FIELDS - ) - - -def _prepend_checkpoint_history( - previous: SavedPlane, current: SavedPlane -) -> SavedPlane: - """Retain attempts and periodic rows preceding a fresh-start resume.""" - history = StepHistory( - **{ - name: np.concatenate( - ( - getattr(previous.step_history, name), - getattr(current.step_history, name), - ) - ) - for name in HISTORY_FIELDS - } - ) - before, after = previous.step_stats, current.step_stats - if before.n_rows and after.n_rows and before.names != after.names: - raise OutputError("checkpoint periodic statistic names changed on resume") - names = before.names if before.n_rows else after.names - values = {} - for name in names: - if before.n_rows and after.n_rows: - values[name] = np.concatenate((before.values[name], after.values[name])) - else: - values[name] = before.values[name] if before.n_rows else after.values[name] - rows = StepStats( - z=np.concatenate((before.z, after.z)), - step=np.concatenate((before.step, after.step + previous.step_history.n_steps)), - values=values, - ) - return dataclasses.replace(current, step_history=history, step_stats=rows) - - -def _differences( - left: JsonValue, right: JsonValue, path: str, found: list[str] -) -> None: - """Collect the dotted paths at which two serialised structures differ. - - - ``right``: the second. - """ - if isinstance(left, dict) and isinstance(right, dict): - if _RECORD_ARRAY in left and _RECORD_ARRAY in right: - # One entry, whatever changed in it: the array has no text to walk. - if dict(left) != dict(right): - found.append(path) - return - for key in sorted(set(left) | set(right)): - child = f"{path}.{key}" if path else key - if key not in left or key not in right: - found.append(child) - else: - _differences(left[key], right[key], child, found) - return - if isinstance(left, list) and isinstance(right, list): - if len(left) != len(right): - found.append(f"{path}[length]") - return - for index, (one, other) in enumerate(zip(left, right, strict=True)): - _differences(one, other, f"{path}[{index}]", found) - return - if _canonical_json(left) != _canonical_json(right): - found.append(path) - - -def _optional_text(value: JsonValue) -> str | None: - """Return a stored optional string, ``None`` when null. - - - Returns: the string, or ``None``. - """ - return None if value is None else str(value) - - -def _refuse_schema(declared: object) -> None: - """Refuse a record of any output schema but :data:`SCHEMA_VERSION`. - - One rule for every other number: an earlier release wrote a lower output schema, a - development version of solprop wrote schemas up to 28, and a later release may write - a higher one, and this release reads none of them (ADR-058). - """ - if type(declared) is int and declared == SCHEMA_VERSION: - return - msg = ( - f"this record declares output schema_version {declared!r}, and this release " - f"of solprop reads output schema {SCHEMA_VERSION} only: a file written by an " - "earlier release, by a development version of solprop or by a later release is " - "not read; read it with the solprop version that wrote it" - ) - raise OutputError(msg) - - -_HASHED_KEYS: tuple[str, ...] = ( - "schema_version", - "simulation_kind", - "grid", - "modes", - "configuration", - "noise", - "stepper", - "spatial", - "power_convention", - "modal", - "materials", - "initial_z", - "tdse", - "quadrature", -) -"""The entries of :meth:`RunMetadata.hashed`, in the order it builds them.""" - - -def _stored_hashed(document: Mapping[str, JsonValue]) -> dict[str, JsonValue]: - """Return the hashed part of a stored record, as the stored text holds it. - - The entries :meth:`RunMetadata.hashed` covers, taken from the document rather than - from the objects rebuilt from it: the general modal identity without its accessor - list (:func:`_hashed_modal`), and the material manifest's hashed part - (:func:`solprop.physdata.selection.hashed_document`). An entry the document lacks - stays absent, so the record digests to the stamp its writer computed. - """ - hashed = {name: document[name] for name in _HASHED_KEYS if name in document} - modal = hashed.get("modal") - if isinstance(modal, dict): - hashed["modal"] = { - key: value for key, value in modal.items() if key != "accessors" - } - if "materials" in hashed: - materials = hashed["materials"] - hashed["materials"] = cast( - "JsonValue", - hashed_document( - None if materials is None else cast("dict[str, object]", materials) - ), - ) - return hashed - - -def _hashed_modal(modal: Mapping[str, object] | None) -> JsonValue: - """Return the general modal identity as the resume key sees it. - - Everything in the record except ``accessors``. Every other entry says what - *was computed* -- which guide, which tables, which basis, which responses, how - far from orthonormal it measured -- and a resume must refuse when one of them - moves. ``accessors`` says what *this package version can compute*, which is a - property of the build and not of the run: leaving it in the key would make - every accessor this package gains invalidate every general modal file ever - written, for a capability those files **gain** rather than lose. The list stays in - the record for a reader, and the resume door checks it as a **subset** instead - (:func:`solprop.output.hdf5.check_cache`). - - - ``modal``: the identity, as :func:`solprop.translate.general.modal_identity` - writes it, or ``None`` for a run that is not a general modal one. - - Returns: the serialised identity without ``accessors``, or ``None``. - """ - if modal is None: - return None - return serialise({key: value for key, value in modal.items() if key != "accessors"}) - - -def _guide_text(guide: object) -> str: - """Return the one printed line describing the guide a general run was built on. - - - Returns: the provider's model, its region count and the resolution its reference - was sampled at, or ``'none'`` when the entry is missing or malformed. - """ - if not isinstance(guide, Mapping): - return "none" - provider = guide.get("provider") - sampling = guide.get("sampling") - regions = guide.get("regions") - model = provider.get("model") if isinstance(provider, Mapping) else "unnamed" - count = len(regions) if isinstance(regions, Sequence) else 0 - if not isinstance(sampling, Mapping): - return f"{model}, {count} region(s)" - return ( - f"{model}, {count} region(s), {sampling.get('n_knots')} knots on " - f"{sampling.get('n_nodes')} nodes, profile error " - f"{float(cast('float', sampling.get('profile_error', float('nan')))):.3e}" - ) - - -def _tables_text(tables: object) -> str: - """Return the one printed line describing a general run's transform tables. - - The ``model_id`` of a general modal reference is the provider's whole physical - identity as a JSON document -- 412 characters on the slice's one-channel - fixture, and longer with more channels -- so printing it filled the row with - the first eighty characters of a serialised mapping and said nothing. What a - reader wants from this row is the gauge, the numeric policy and something short - that two files can be compared by, which is what a digest is for. The full - document is in ``metadata.modal['tables']['model_id']``. - - - Returns: a 12-hex digest of the model identity, the gauge and the policy, or - ``'none'`` when the entry is missing or malformed. - """ - if not isinstance(tables, Mapping): - return "none" - model = tables.get("model_id") - digest = ( - hashlib.sha256(str(model).encode("utf-8")).hexdigest()[:12] - if model is not None - else "unnamed" - ) - # The digest first: the row is truncated to the block's width, and it is the - # entry two files are compared by. - return ( - f"model {digest}, gauge {tables.get('gauge_id')}, " - f"policy {tables.get('policy_id')}" - ) - - -def _responses_text(responses: object) -> str: - """Return the one printed line describing a general run's regional responses. - - - ``responses``: the ``responses`` entry of a general modal identity, or ``None`` - for a guide whose regions are all linear. - - Returns: the compiled model, the responding region count and each region's - declared susceptibility, or ``'none'`` when no region responds. - """ - if not isinstance(responses, Mapping): - return "none" - chi3 = responses.get("chi3") - values = ( - ", ".join(f"{float(one):.3e}" for one in chi3) - if isinstance(chi3, Sequence) and not isinstance(chi3, str) - else "unrecorded" - ) - return ( - f"{responses.get('model_id')}, {responses.get('policy_id')}, " - f"{responses.get('n_responses')} region(s), chi3 {values}" - ) - - -def _flow_text(flow: object) -> str: - """Return the printed summary of a coupled-flow entry, or that there is none.""" - if not isinstance(flow, Mapping): - return "diagonal" - ratios = [ - float(cast("float", flow.get(name, 0.0))) - for name in ("transverse_ratio", "vacuum_ratio", "quasi_static") - ] - methods = flow.get("section_methods") - named = ( - ", ".join(str(one) for one in methods) - if isinstance(methods, Sequence) and not isinstance(methods, str) - else "none" - ) - conditioning = float(cast("float", flow.get("inverse_condition_bound", 1.0))) - return ( - f"{flow.get('model')}, {named}, worst ratio {max(ratios):.3e}, " - f"conditioning {conditioning:.3e}" - ) - - -def _modal_rows(modal: Mapping[str, object] | None) -> list[tuple[str, object]]: - """Return the printed rows of a general modal identity, or none at all. - - Every field of :attr:`RunMetadata.modal` appears in one of these rows, - including the ``flow`` entry of a coherent wall, - so that a printed record says which guide, which table and which basis produced - the file; the rows are absent altogether for a route that has no such identity. - - - ``modal``: the identity, or ``None`` for a route that declares none. - """ - if modal is None: - return [] - tables = modal.get("tables") - metric = modal.get("metric") - guide = modal.get("guide") - band = modal.get("active_band") - channels = modal.get("channels") - names = ( - ", ".join(str(one.get("display_label")) for one in channels) - if isinstance(channels, Sequence) and not isinstance(channels, str) - else "none" - ) - published = modal.get("accessors") - accessors = ( - cast("Sequence[object]", published) - if isinstance(published, Sequence) and not isinstance(published, str) - else None - ) - return [ - ("basis", f"{modal.get('basis')}, {modal.get('n_components')} components"), - ("channels", names), - ("channel count", modal.get("n_channels")), - ("tables", _tables_text(tables)), - ( - "metric", - f"{metric.get('kind')}, defect " - f"{float(metric.get('defect', float('nan'))):.3e}, certificate " - f"{metric.get('certificate_id')}" - if isinstance(metric, Mapping) - else "none", - ), - ("responses", _responses_text(modal.get("responses"))), - ("guide", _guide_text(guide)), - ( - "active band", - f"{engineering(float(band[0]), 'rad/s')} to " - f"{engineering(float(band[1]), 'rad/s')}" - if isinstance(band, Sequence) and len(band) == 2 - else "none", - ), - ("frame", modal.get("frame")), - ("power weight", modal.get("power_weight")), - ("flow", _flow_text(modal.get("flow"))), - ("noise basis", modal.get("noise_reference_basis") or "none"), - ( - "accessors", - # The count first: the row is truncated to the block's width, and a - # reader who cannot see the last name can still see how many there are - # and read the list off `metadata.modal['accessors']`. - f"{len(accessors)}: {', '.join(str(one) for one in accessors)}" - if accessors is not None - else "unrestricted", - ), - ] - - -def _material_rows(manifest: MaterialManifest) -> list[tuple[str, object]]: - """Return the printed row of a material manifest, or none for an empty one.""" - if manifest.empty: - return [] - names = ", ".join(sorted({record.name for record in manifest.records})) or "none" - return [("materials", f"optmat {manifest.optmat_version}: {names}")] - - -@dataclass(frozen=True, eq=False, kw_only=True) -class RunMetadata(Displayed): - """Everything needed to say what produced a file, and to refuse a wrong resume. - - Equality is by identity (``eq=False``); two runs are the same run when their - :meth:`configuration_hash` agree, which is what a resume actually asks. - - Attributes - ---------- - grid : GridRecord - The grid the run was computed on. - modes : tuple - One :class:`ModeRecord` per propagated mode, in state order. - simulation_kind : {'mode_averaged', 'multimode', 'radial', 'free', 'slab'} - Which transform the run used. - configuration : dict - The physics objects that built the run -- responses, profiles, pulses, - boundaries -- as :func:`serialise` records them. Nothing is silently dropped: - a callable is present as its ``repr`` with ``reproducible: False``. - stepper : StepperRecord - The solver settings and the schedule taken. - noise : NoiseRecord or None - The shot-noise realisation, or ``None`` for a noiseless run. - noise_resolution : NoiseResolution or None - How the noise model was chosen (SEP 37): the requested and the resolved - model and any fallback's reason. Outside the hash; ``None`` when the record's - builder did not say, as for a record built by hand. - defaults : DefaultsResolution or None - Which of the integrator, the controller and the collar scheme took the - route's default. Outside the hash; ``None`` when the record's builder did not - say. - backend : BackendRecord or None - What executed the run, written by the driver from the backend's own - ``metadata()``; ``None`` for a record built before a backend existed. Outside - the hash, like the versions and the provenance beside it: a run continued on - the other backend is the same run. - versions : dict - Package versions, from :func:`package_versions`. Outside the hash. - git : GitProvenance or None - Provenance of the running checkout, or ``None``. Outside the hash. - created : str - When the run started, ISO 8601 in UTC. Outside the hash. - schema_version : int - Version of this record layout, :data:`SCHEMA_VERSION`; any other is refused. - spectral_convention : str - Sign convention of the stored field, always ``'optics'``. - field_layout : str - Axis order of the stored field. - spatial : collections.abc.Mapping or None - Spatial geometry, axis meanings, integration measures, field units and the - plane measure (``flux_model`` and ``flux_plan``, SEP 34). Required for the - radial, free and slab routes and ``None`` for modal runs. Included in the - resume key. - modal : collections.abc.Mapping or None - The identity of a **general modal guide**: the ordered channel labels, the - table, metric, response and provider identities, the sampling the reference - is rebuilt from, the gauge, the frame, the active band, the noise reference - basis and the accessors this basis admits, as - :func:`solprop.translate.general.modal_identity` writes it. ``None`` for - every other route. Included in the resume key, less the accessor list, so - that a run continued against a different guide, a different table or a - reordered mode axis is refused by the field that changed - (:mod:`solprop.output.capability`). - materials : solprop.physdata.selection.MaterialManifest - The run's **material manifest**: the optmat version, the - identity of every optmat model the run resolved, its species, compositions - and solids, the policies in force and what was measured about them. Empty - for a run of solprop's own materials. Its identities and policies are in the - resume key (:meth:`~solprop.physdata.selection.MaterialManifest.hashed`), so - a run continued with another model, policy or optmat version is refused by - the entry that changed. - initial_z : float - Absolute launch position in metres; zero for a run launched at the entrance. - Included in the resume key. - tdse : collections.abc.Mapping or None - Resolved scientific atomic descriptor, included in the resume key; ``None`` - for a run without a TDSE response. Provider execution and device facts - belong to the backend record. - quadrature : collections.abc.Mapping or None - The transverse rule of a resolved modal run, as - :meth:`solprop.modes.quadrature.TransverseQuadrature.record` writes it: the - family, the counts, the nodes and the physical weights, in the resume key - (SEP 19). ``None`` for the mode-averaged and spatial routes. - reference : collections.abc.Mapping or None - The plane-reference record (SEP 31): ``carrier``, the - grid's reference frequency in rad/s; ``origin``, the reference of the run's - path origin (phase, delay, calibrated), inherited from a parent plane for a - run launched from one; ``parent``, that parent's provenance or ``None``; - ``frame``, the frame integral of - :func:`solprop.translate.coupling.frame_record`, or ``None`` for an operator - supplied without frame tables; and ``basis``, the route and the basis - identity (:func:`solprop.coupling.plane.basis_identity`) at each planned plane - ``z``, the launch plane first. :func:`solprop.coupling.plane.reference_at` - rebuilds any saved plane's reference from it. Derived numbers, so outside - the hash; the inherited origin enters it through - ``configuration["coupling"]``. Every run records one; ``None`` on a record - built without it, as by hand, whose planes cannot be exchanged - (:func:`solprop.processing.planes.field_plane`). - transverse : collections.abc.Mapping or None - A free-space run's resolved transverse boundary (SEP 40), as - :meth:`solprop.spatial_absorber.SpatialAbsorberDesign.record` writes it: the - safety ramp's start and strength, and a collar's strength, band, threshold - and step limit. Derived numbers, so outside the hash; the specification is in - ``configuration["spatial_absorber"]`` where one was given. ``None`` without - a transverse boundary. - - Examples - -------- - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.output.schema import GridRecord, RunMetadata, StepperRecord - >>> from solprop.stepper import DOPRI5, PIController - >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - >>> metadata = RunMetadata( - ... grid=GridRecord.from_grid(grid), - ... modes=(), - ... simulation_kind="mode_averaged", - ... configuration={"gamma": 0.11}, - ... stepper=StepperRecord.from_settings( - ... DOPRI5, PIController(rtol=1e-8, atol=0.0) - ... ), - ... ) - >>> len(metadata.configuration_hash()) - 64 - >>> metadata.spectral_convention - 'optics' - """ - - grid: GridRecord - modes: tuple[ModeRecord, ...] - simulation_kind: SimulationKind - configuration: Mapping[str, object] - stepper: StepperRecord - noise: NoiseRecord | None = None - backend: BackendRecord | None = None - versions: Mapping[str, str] = dataclasses.field(default_factory=package_versions) - git: GitProvenance | None = dataclasses.field(default_factory=GitProvenance.detect) - created: str = "" - schema_version: int = SCHEMA_VERSION - spectral_convention: str = SPECTRAL_CONVENTION - field_layout: str = FIELD_LAYOUT - spatial: Mapping[str, object] | None = None - modal: Mapping[str, object] | None = None - materials: MaterialManifest = dataclasses.field(default_factory=MaterialManifest) - initial_z: float = 0.0 - tdse: Mapping[str, object] | None = None - quadrature: Mapping[str, object] | None = None - reference: Mapping[str, object] | None = None - transverse: Mapping[str, object] | None = None - noise_resolution: NoiseResolution | None = None - defaults: DefaultsResolution | None = None - _stored: dict[str, JsonValue] | None = dataclasses.field( - default=None, init=False, repr=False, compare=False, metadata={"record": False} - ) - _stored_arrays: dict[str, np.ndarray] | None = dataclasses.field( - default=None, init=False, repr=False, compare=False, metadata={"record": False} - ) - - def __repr__(self) -> str: - """Return a one-line description: what kind of run, on what, made when. - - Returns - ------- - str - For example ``RunMetadata(modal, field grid of 8192, 2 modes, native, - 2026-09-17T12:00:00+00:00)``. - """ - backend = "no backend" if self.backend is None else self.backend.name - return line( - "RunMetadata", - [ - ("", self.simulation_kind), - ("", f"{self.grid.kind} grid of {self.grid.n_t}"), - ("", f"{len(self.modes)} modes"), - ("", backend), - ("", self.created or "undated"), - ], - ) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - grid = self.grid - kinds = [mode.kind.rsplit(".", 1)[-1] for mode in self.modes] - versions = ", ".join(f"{name} {value}" for name, value in self.versions.items()) - return f"RunMetadata: a {self.simulation_kind} run", [ - ( - "grid", - f"{grid.kind}, {grid.n_t} samples, reference " - f"{engineering(grid.reference_wavelength, 'm')}, length " - f"{engineering(grid.length, 'm')}", - ), - ("modes", ", ".join(kinds) or "none"), - ("backend", "none recorded" if self.backend is None else self.backend.name), - ("created", self.created or "undated"), - ( - "convention", - f"{self.spectral_convention} spectra, {self.power_convention} " - f"amplitudes, schema {self.schema_version}", - ), - ("versions", versions), - ("configuration", self.configuration_hash()), - *_modal_rows(self.modal), - *_material_rows(self.materials), - ] - - def __post_init__(self) -> None: - """Refuse another schema, stamp the creation time and adopt plain copies. - - Raises - ------ - OutputError - If the record declares an output schema other than :data:`SCHEMA_VERSION`, - if `initial_z` is not finite and nonnegative, or if a spatial record is - missing or names no plane measure. - """ - _refuse_schema(self.schema_version) - if not self.created: - object.__setattr__(self, "created", datetime.now(UTC).isoformat()) - object.__setattr__(self, "modes", tuple(self.modes)) - object.__setattr__(self, "configuration", dict(self.configuration)) - object.__setattr__(self, "versions", dict(self.versions)) - for name in ( - "spatial", - "modal", - "reference", - "tdse", - "quadrature", - "transverse", - ): - value = getattr(self, name) - if value is not None: - object.__setattr__(self, name, dict(value)) - if not np.isfinite(self.initial_z) or self.initial_z < 0: - raise OutputError("initial_z must be finite and nonnegative") - if self.simulation_kind in ("radial", "free", "slab") and self.spatial is None: - msg = "spatial metadata requires a spatial geometry record" - raise OutputError(msg) - if self.spatial is not None and "flux_model" not in self.spatial: - msg = ( - "spatial metadata must name its flux_model and carry its flux_plan " - "(SEP 34)" - ) - raise OutputError(msg) - - @property - def transverse_measure(self) -> TransverseMeasure: # numpydoc ignore=RT01 - """What the run's transverse integrals measure. - - A run on modes uniform in y (:class:`solprop.modes.slab.SlabMode`) records - ``configuration["transverse_measure"] = "per_unit_width"``: its powers are - W/m, its energies J/m and its intensities still W/m². Every other run measures - an area. The key sits in the free-form configuration and is written only - where it is not the default. - - ``"area"`` or ``"per_unit_width"``. - - Raises - ------ - OutputError - If the stored value is neither. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata - >>> _example_metadata().transverse_measure - 'area' - """ - value = self.configuration.get("transverse_measure", "area") - if value not in ("area", "per_unit_width"): - msg = ( - f"configuration.transverse_measure is {value!r}; a run measures " - "'area' or 'per_unit_width'" - ) - raise OutputError(msg) - return cast("TransverseMeasure", value) - - @property - def power_convention(self) -> str: # numpydoc ignore=RT01 - """Amplitude label of the stored field. - - Always ``"poynting"``: the interface modal amplitude, whose squared - modulus is the true power in watts (``docs/dev/conventions.md`` §4). - """ - return "poynting" - - def hashed(self) -> dict[str, JsonValue]: - """Return the part of the record the resume key is taken over. - - Everything that describes *what was computed*, and nothing that describes - *when* or *with which build*: the wall-clock time, the package versions and the - git provenance are excluded, so that a resume survives an unrelated commit or a - dirty working tree, as long as they move no number the record holds; a new - library or platform may move one, and the resume is then refused. - ``modal.accessors`` is excluded for the same reason -- it is what this build can - compute, not what this run did (``_hashed_modal``). - - Returns - ------- - dict - The serialised schema version, grid, modes, simulation kind, configuration, - noise and stepper settings, the spatial record, the power convention, the - general modal identity, the material manifest's identities and policies, - the launch position, the TDSE descriptor and the transverse quadrature. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata - >>> sorted(_example_metadata().hashed())[:4] - ['configuration', 'grid', 'initial_z', 'materials'] - """ - return dict(self._hashed_record) - - @functools.cached_property - def _hashed_record(self) -> dict[str, JsonValue]: - """The serialised hashed part, built once per record (:meth:`hashed`). - - A run asks for it several times -- the resume key twice and the stored JSON - once -- and serialising the configuration was most of an output's opening - (SEP 25 section 6.3). The record is frozen, so the first answer stands; - :meth:`hashed` hands out a shallow copy, which its callers extend. - """ - if self._stored is not None: - return self._stored - return { - "schema_version": self.schema_version, - "simulation_kind": self.simulation_kind, - "grid": serialise(self.grid), - "modes": serialise(list(self.modes)), - "configuration": serialise(dict(self.configuration)), - "noise": serialise(self.noise), - "stepper": serialise(self.stepper), - "spatial": serialise(self.spatial), - "power_convention": self.power_convention, - # All of the general-guide identity but the accessor list, which is the - # build's capability and not the run's description (_hashed_modal). - "modal": _hashed_modal(self.modal), - # The material manifest's identities and policies; a run of solprop's - # own materials keys on `None`. - "materials": serialise(self.materials.hashed()), - "initial_z": self.initial_z, - "tdse": serialise(self.tdse), - "quadrature": serialise(self.quadrature), - } - - def configuration_hash(self) -> str: - """Return the resume key: a digest of everything that was computed. - - A SHA-256 of the canonical JSON of :meth:`hashed`, so it is stable across - processes -- unlike :func:`hash`, which is salted per interpreter. It covers the - physics and the numerics, and not only the statistic names and the state shape, - so a resume continues exactly when the two hashes are equal (ADR-066). - - Returns - ------- - str - Sixty-four lowercase hexadecimal characters. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata - >>> metadata = _example_metadata() - >>> metadata.configuration_hash() == metadata.configuration_hash() - True - """ - return self._configuration_digest - - @functools.cached_property - def _configuration_digest(self) -> str: - """The resume key, computed once per record (:meth:`configuration_hash`).""" - text = _canonical_json(self._hashed_record) - return hashlib.sha256(text.encode("utf-8")).hexdigest() - - def with_backend(self, backend: BackendRecord) -> "RunMetadata": - """Return this record with a run's execution facts, its resume key kept. - - The backend record is outside the hash, so the hashed part and the resume key - already computed stand: a run opening its output does not serialise and digest - its configuration a second time, which cost a capillary run 30 ms and a large - launch seconds (solprop issue #347). - - Parameters - ---------- - backend : BackendRecord - The execution facts of the prepared backend. - - Returns - ------- - RunMetadata - A copy of this record with `backend` in place of its own. - - Examples - -------- - >>> from solprop.output.schema import BackendRecord, _example_metadata - >>> metadata = _example_metadata() - >>> backend = BackendRecord(name="numpy_ref", propagator_mode="tabulate") - >>> executed = metadata.with_backend(backend) - >>> executed.backend.name - 'numpy_ref' - >>> executed.configuration_hash() == metadata.configuration_hash() - True - """ - replaced = dataclasses.replace(self, backend=backend) - # functools.cached_property stores in the instance dictionary, past the - # frozen dataclass's __setattr__. - for name in ("_hashed_record", "_configuration_digest"): - if name in self.__dict__: - replaced.__dict__[name] = self.__dict__[name] - return replaced - - def differences(self, other: "RunMetadata") -> tuple[str, ...]: - """Return the dotted paths at which two records describe different runs. - - This is what a refused resume names, so that the reason is the field that - changed rather than "the hash does not agree". - - Parameters - ---------- - other : RunMetadata - The record to compare against. - - Returns - ------- - tuple - Dotted paths into :meth:`hashed`, in depth-first order; empty when the two - describe the same run. - - Examples - -------- - >>> import dataclasses - >>> from solprop.output.schema import _example_metadata - >>> one = _example_metadata() - >>> two = dataclasses.replace(one, configuration={"gamma": 0.22}) - >>> one.differences(two) - ('configuration.gamma',) - """ - found: list[str] = [] - _differences(self.hashed(), other.hashed(), "", found) - return tuple(found) - - def summarise_differences( - self, - other: "RunMetadata", - *, - shown: int = _DIFFERENCES_SHOWN, - ) -> str: - """Say how two records differ, briefly enough to read in a refusal. - - The paths of :meth:`differences`, with every array's element paths grouped - under the array's name and counted. Every part of the record that changed - (``configuration``, ``materials``, ``noise``, ``schema_version``, ...) is - named, with its first `shown` entries and a count of the rest, so that no - cause is hidden behind another part's many entries. Where the noise differs - and this record's realisation was drawn from a known seed, the summary says - which seed draws it again. - - Parameters - ---------- - other : RunMetadata - The record to compare against; this one is the stored record. - shown : int, optional - How many entries to name in each part of the record. - - Returns - ------- - str - ``"these fields changed: ..."``, empty of entries when the two describe - the same run. - - Examples - -------- - >>> import dataclasses - >>> from solprop.output.schema import _example_metadata - >>> one = _example_metadata() - >>> two = dataclasses.replace( - ... one, configuration={"gamma": 0.22, "betas": [1.0, 2.0, 3.0]} - ... ) - >>> one.summarise_differences(two) - 'these fields changed: configuration.betas, configuration.gamma' - >>> three = dataclasses.replace(one, configuration={"gamma": [0.1, 0.2, 0.3]}) - >>> three.summarise_differences( - ... dataclasses.replace(one, configuration={"gamma": [0.4, 0.5, 0.6]}) - ... ) - 'these fields changed: configuration.gamma (3 elements)' - """ - # An array is recorded as its real and imaginary parts' nested lists, so an - # element is a stem and its trailing indices, counted once for both parts. - groups: dict[str, dict[str, str]] = {} - for path in self.differences(other): - container = _ELEMENT_INDICES.sub("", path) - stem = _ARRAY_PART.sub("", container) - groups.setdefault(stem, {}).setdefault(path[len(container) :], path) - parts: dict[str, list[str]] = {} - for stem, elements in groups.items(): - part = _PATH_SEPARATOR.split(stem, maxsplit=1)[0] - parts.setdefault(part, []).append( - next(iter(elements.values())) - if len(elements) == 1 - else f"{stem} ({len(elements)} elements)" - ) - named: list[str] = [] - for part, entries in parts.items(): - named.extend(entries[:shown]) - if len(entries) > shown: - named[-1] += f" (and {len(entries) - shown} more in {part})" - text = ", ".join(named) - stored = self.noise - if "noise" in parts and stored is not None and stored.seed is not None: - text += ( - f"; the stored run drew its {stored.model} noise from seed " - f"{stored.seed}: pass seed={stored.seed} to draw it again" - ) - return f"these fields changed: {text}" - - def record_arrays(self) -> dict[str, np.ndarray]: - """Return the arrays stored beside the record, by digest. - - What :meth:`to_json`'s references name: each numeric array of more than - :data:`RECORD_ARRAY_LIMIT` elements. A store keeps them with the document, and - :meth:`from_json` takes them back. - - Returns - ------- - dict - The arrays, not copied, each by the digest of its bytes with its dtype and - shape. - - Examples - -------- - >>> import dataclasses - >>> import numpy as np - >>> from solprop.output.schema import _example_metadata - >>> metadata = dataclasses.replace( - ... _example_metadata(), configuration={"rate": np.zeros(5000)} - ... ) - >>> [array.size for array in metadata.record_arrays().values()] - [5000] - """ - found = dict(self._stored_arrays or {}) - - def _walk(value: object) -> None: - """Collect the arrays of a serialised structure.""" - if isinstance(value, _ArrayReference): - found[str(value[_RECORD_ARRAY])] = value.array - elif isinstance(value, dict): - for item in value.values(): - _walk(item) - elif isinstance(value, list): - for item in value: - _walk(item) - - _walk(self._document) - return found - - def to_json(self) -> str: - """Return the whole record as one JSON document. - - An array of more than :data:`RECORD_ARRAY_LIMIT` elements is a reference to what - :meth:`record_arrays` holds. - - Returns - ------- - str - Canonical JSON carrying the hashed part plus the whole general modal - identity, whose published accessor list the hashed part leaves out, the - whole material manifest, the plane-reference record, the noise model's - resolution, the conventions, the backend record, the versions, the git - provenance and the creation time. - - Examples - -------- - >>> import json - >>> from solprop.output.schema import _example_metadata - >>> sorted(json.loads(_example_metadata().to_json()))[:3] - ['backend', 'configuration', 'created'] - """ - return _canonical_json(self._document) - - @functools.cached_property - def _document(self) -> dict[str, JsonValue]: - """The whole serialised record, built once per record (:meth:`to_json`). - - - Returns: the hashed part and what is stored beside it, JSON-ready. - """ - document = self.hashed() - # The whole identity, including the accessor list `hashed` leaves out: what a - # file records and what its resume key is taken over are two different - # questions, and the published accessors answer only the first - # (:func:`_hashed_modal`). - document["modal"] = serialise(self.modal) - # The whole manifest, measurements included, beside its hashed part. - document["materials"] = serialise(self.materials.to_dict()) - # Derived from the hashed problem, so stored beside the key, not in it: a - # resume on another build may evaluate the frame to a different last bit. - document["reference"] = serialise(self.reference) - # The transverse boundary's derived values (SEP 40), from a root-finder: - # beside the key, so that a resume on another build is not refused. - document["transverse"] = serialise(self.transverse) - # How the model was chosen, beside the key: the resolved model is in the - # hashed noise record already, and a request is not part of the physics. - document["noise_resolution"] = serialise(self.noise_resolution) - # Which numerics took the route's default, beside the key for the same reason. - document["defaults"] = serialise(self.defaults) - document["spectral_convention"] = self.spectral_convention - document["field_layout"] = self.field_layout - document["backend"] = serialise(self.backend) - document["versions"] = serialise(dict(self.versions)) - document["git"] = serialise(self.git) - document["created"] = self.created - return document - - @classmethod - def from_json( - cls, - text: str, - arrays: Mapping[str, object] | None = None, - ) -> "RunMetadata": - """Return the record a JSON document describes. - - The configuration comes back as the plain structures :func:`serialise` made of - it, not as the physics objects: a callable was recorded by ``repr`` and cannot - be rebuilt. That is enough for the hash, which is what a resume compares. - - Parameters - ---------- - text : str - A document produced by :meth:`to_json`. - arrays : collections.abc.Mapping or None, optional - What :meth:`record_arrays` gave for it, by digest, or an HDF5 group holding - them. Each comes back in place of its reference, its digest checked. - - Returns - ------- - RunMetadata - The record, hashing identically to the one that produced ``text``. - - Raises - ------ - OutputError - If the document is not valid JSON, declares an output schema other than - :data:`SCHEMA_VERSION`, a required field is missing, or an array it names - is absent from `arrays` or does not have its digest. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata - >>> metadata = _example_metadata() - >>> back = RunMetadata.from_json(metadata.to_json()) - >>> back.configuration_hash() == metadata.configuration_hash() - True - """ - try: - document = json.loads(text) - except json.JSONDecodeError as error: - msg = f"stored metadata is not valid JSON: {error}" - raise OutputError(msg) from error - if not isinstance(document, dict) or "schema_version" not in document: - msg = "stored metadata is missing or malformed: no schema_version" - raise OutputError(msg) - # The version first: a file of another schema is refused by its version, - # not by the first field its layout lacks. - _refuse_schema(document["schema_version"]) - # The record as written, references and all, is what digests to its stamp. - written = document - held: dict[str, np.ndarray] = {} - document = cast("dict[str, Any]", _resolved(document, arrays or {}, held)) - - # Another build of this schema may lack a top-level entry. - stored = cast( - "Callable[..., Any]", functools.partial(_stored_entry, document, path=()) - ) - - try: - noise = stored("noise") - resolution = stored("noise_resolution") - # Absent from a record written before the defaults were recorded. - defaults = stored("defaults") - git = stored("git") - backend = stored("backend") - metadata = cls( - grid=_rebuild(GridRecord, document["grid"], ("grid",)), - modes=tuple( - _rebuild(ModeRecord, one, ("modes", index)) - for index, one in enumerate(document["modes"]) - ), - simulation_kind=document["simulation_kind"], - configuration=document["configuration"], - stepper=StepperRecord.from_record(document["stepper"]), - noise=None - if noise is None - else _rebuild(NoiseRecord, noise, ("noise",)), - backend=None if backend is None else _rebuild(BackendRecord, backend), - versions=cast("Mapping[str, str]", stored("versions", default={})), - git=None if git is None else _rebuild(GitProvenance, git), - created=str(stored("created", default="")), - schema_version=document["schema_version"], - spectral_convention=str( - stored("spectral_convention", default=SPECTRAL_CONVENTION) - ), - field_layout=str(stored("field_layout", default=FIELD_LAYOUT)), - spatial=stored("spatial"), - modal=stored("modal"), - materials=MaterialManifest.from_dict(stored("materials")), - initial_z=stored("initial_z", default=0.0), - tdse=stored("tdse"), - quadrature=stored("quadrature"), - reference=stored("reference"), - transverse=stored("transverse"), - noise_resolution=None - if resolution is None - else _rebuild(NoiseResolution, resolution), - defaults=None - if defaults is None - else _rebuild(DefaultsResolution, defaults), - ) - except (KeyError, TypeError, MaterialError) as error: - msg = f"stored metadata is missing or malformed: {error}" - raise OutputError(msg) from error - # The record as written: it digests to its own stamp, whatever today's code - # would write. - object.__setattr__(metadata, "_stored", _stored_hashed(written)) - object.__setattr__(metadata, "_stored_arrays", held) - return metadata - - -def _example_metadata() -> RunMetadata: - """Return a minimal record, so the examples above stay one line each. - - A documentation and experimentation helper, not part of the public surface: build a - real record with the :class:`RunMetadata` constructor, whose own example shows how. - """ - grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - return RunMetadata( - grid=GridRecord.from_grid(grid), - modes=(), - simulation_kind="mode_averaged", - configuration={"gamma": 0.11}, - stepper=StepperRecord(tableau="DOPRI5", controller={"rtol": 1e-8, "atol": 0.0}), - ) - - -# ---------------------------------------------------------------- the save protocol - - -def resolve_metadata_write( - existing: RunMetadata | None, new: RunMetadata, *, force: bool = False -) -> bool: - """Decide whether a handler may write metadata, under one rule for both handlers. - - Metadata is written once. A second write describing the same run is a no-op; one - describing a different run is a mistake and raises, naming the fields that changed, - unless the caller insists with ``force``, in which case the record is replaced and - the replacement is logged. - - "The same run" means the same :meth:`RunMetadata.configuration_hash`, so a rerun of - identical physics under a newer NumPy, or from a checkout that has since gained a - commit, is still the same run. - - Parameters - ---------- - existing : RunMetadata or None - What the handler already holds, or ``None`` when it holds nothing. - new : RunMetadata - What the caller is offering. - force : bool, optional - Whether to overwrite a different record instead of refusing. - - Returns - ------- - bool - Whether the caller should write ``new``. - - Raises - ------ - OutputError - If a different record is already held and ``force`` is false. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata, resolve_metadata_write - >>> metadata = _example_metadata() - >>> resolve_metadata_write(None, metadata) - True - >>> resolve_metadata_write(metadata, metadata) - False - """ - if existing is None: - return True - if existing.configuration_hash() == new.configuration_hash(): - return False - changed = existing.summarise_differences(new) - if not force: - msg = ( - f"metadata has already been written for a different run; {changed}. " - "Pass force=True to overwrite it." - ) - raise OutputError(msg) - logger.warning("overwriting run metadata; %s", changed) - return True - - -FieldsChoice = Literal["none", "planes", "all"] -"""Which saved planes keep their field: none of them, the named ones, or all.""" - - -def field_retention_refusal(fields: object, field_planes: object) -> str | None: - """Say what is wrong with a field-retention request, or that nothing is. - - The one validator behind :class:`solprop.driver.simulation.SimulationSettings`, - :func:`solprop.driver.run.run_problem`, :func:`solprop.driver.scan.run_scan` and - :class:`FieldRetention`; each door raises its own layer's error with the message. - - Parameters - ---------- - fields : object - The request, one of ``"none"``, ``"planes"`` and ``"all"``. - field_planes : object - Saved-plane indices, the entrance as 0 and negative from the end; required - exactly when `fields` is ``"planes"``. - - Returns - ------- - str or None - Why the request is refused, or ``None`` when it is valid. - - Examples - -------- - >>> from solprop.output.schema import field_retention_refusal - >>> field_retention_refusal("planes", [0, -1]) is None - True - >>> field_retention_refusal("planes", []) - "field_planes is required exactly when fields='planes'" - """ - if fields not in ("none", "planes", "all"): - return f"fields must be none, planes or all, got {fields!r}" - try: - planes = tuple(cast("Sequence[object]", field_planes)) - except TypeError: - return ( - "field_planes must be a sequence of saved-plane indices, got " - f"{field_planes!r}" - ) - if any( - isinstance(index, bool) or not isinstance(index, int | np.integer) - for index in planes - ): - return f"field_planes must hold integer saved-plane indices, got {planes!r}" - if (fields == "planes") != bool(planes): - return "field_planes is required exactly when fields='planes'" - return None - - -@dataclass(frozen=True) -class FieldRetention: - """Which saved planes of a run keep their field (SEP 22 section 5.1). - - A run is **homogeneous**: it keeps the field on every plane, on the planes it - names, or on none, and its statistics on every plane either way. A plane without - its field stores no ``field`` and no ``noise_phase``; what it saves is its - position, its statistics and its step history. - - Attributes - ---------- - fields : {'none', 'planes', 'all'} - Keep no field, the fields of `field_planes`, or every field (the default). - field_planes : tuple of int - Saved-plane indices for ``"planes"``, the entrance as 0 and negative from the - end until :meth:`resolved`; empty otherwise. - - Raises - ------ - OutputError - If the request is not valid (:func:`field_retention_refusal`). - - Examples - -------- - >>> from solprop.output.schema import FieldRetention - >>> kept = FieldRetention("planes", (0, -1)).resolved(5) - >>> kept.field_planes, kept.keeps(4), kept.keeps(2), kept.kept(5) - ((0, 4), True, False, 2) - """ - - fields: FieldsChoice = "all" - field_planes: tuple[int, ...] = () - - def __post_init__(self) -> None: - """Validate the request and adopt plain integer indices. - - Raises - ------ - OutputError - Naming what is wrong. - """ - refusal = field_retention_refusal(self.fields, self.field_planes) - if refusal is not None: - raise OutputError(refusal) - object.__setattr__( - self, "field_planes", tuple(int(index) for index in self.field_planes) - ) - - def resolved(self, n_planes: int) -> "FieldRetention": - """Return the rule with its indices counted from the entrance, sorted, unique. - - Parameters - ---------- - n_planes : int - The planes the run saves, the entrance included. - - Returns - ------- - FieldRetention - The same rule with non-negative indices. - - Raises - ------ - OutputError - If an index lies outside the run's planes. - """ - resolved = set() - for index in self.field_planes: - plane = index + n_planes if index < 0 else index - if not 0 <= plane < n_planes: - msg = f"field plane {index} is outside a run of {n_planes} saved planes" - raise OutputError(msg) - resolved.add(plane) - return FieldRetention(self.fields, tuple(sorted(resolved))) - - def keeps(self, index: int) -> bool: - """Return whether the saved plane at `index` keeps its field. - - Parameters - ---------- - index : int - The plane's index, the entrance as 0; the rule must be :meth:`resolved`. - - Returns - ------- - bool - True for every plane of an ``"all"`` run, a named plane of a ``"planes"`` - run, and no plane of a ``"none"`` run. - """ - if self.fields == "all": - return True - return index in self.field_planes - - def kept(self, n_planes: int) -> int: - """Return how many of a run's planes keep their field. - - Parameters - ---------- - n_planes : int - The planes the run saves; the rule must be :meth:`resolved`. - - Returns - ------- - int - The planes whose field is stored. - """ - if self.fields == "all": - return n_planes - return len(self.field_planes) - - def describe(self) -> str: - """Return the rule in words, for a refusal naming it. - - Returns - ------- - str - For example ``"fields='planes' (saved planes 0, 4)"``. - """ - if self.fields == "planes": - planes = ", ".join(str(index) for index in self.field_planes) - return f"fields='planes' (saved planes {planes})" - return f"fields={self.fields!r}" - - -@dataclass(frozen=True) -class PlaneLayout: - """What every plane of one run has in common, fixed by the first plane saved. - - Attributes - ---------- - field_shape : tuple of int - ``(n_modes, n_pol, n_omega)`` of the stored field, or of the run's state when - it keeps no field; empty when neither is known. - stat_names : tuple of str - The statistic names, sorted. - has_noise_phase : bool - Whether a noise phase is stored beside each stored field. - step_stat_names : tuple of str - The statistics the between-planes channel carries, sorted; empty for a run - without a periodic subset, which is the default. - retention : FieldRetention - Which planes keep their field, resolved; every plane by default. - - Examples - -------- - >>> from solprop.output.schema import PlaneLayout - >>> layout = PlaneLayout( - ... field_shape=(1, 1, 8), stat_names=("energy",), has_noise_phase=False - ... ) - >>> layout.field_shape, layout.stat_names, layout.retention.fields - ((1, 1, 8), ('energy',), 'all') - """ - - field_shape: tuple[int, ...] - stat_names: tuple[str, ...] - has_noise_phase: bool - step_stat_names: tuple[str, ...] = () - retention: FieldRetention = FieldRetention() - - -@runtime_checkable -class Output(Protocol): - """What every output handler does, and all that processing may assume of one. - - One protocol so that :class:`solprop.output.memory.MemoryOutput` and - :class:`solprop.output.hdf5.HDF5Output` are interchangeable: a run is - written to memory for a scan and to disk for a long propagation without the driver - knowing which. - - Everything a handler hands back through ``__getitem__`` is *as stored*, so the field - is in the optics convention; ``fields`` is the accessor that also offers the - internal one, for direct comparison with a file stored in the FFT convention. - """ - - def open(self, metadata: RunMetadata, *, force: bool = False) -> None: - """Begin a run, recording what produced it. - - Parameters - ---------- - metadata : RunMetadata - The record of the run about to start. - force : bool, optional - Whether to overwrite metadata already held for a different run. - """ - ... - - def save(self, plane: SavedPlane) -> None: - """Store one saved plane. - - A run gives the handler the backend's observation itself, not a copy: the - plane's arrays are read-only, nothing writes to them again, and a handler may - keep them or reduce them and let them go, with no copy of its own. - - Parameters - ---------- - plane : SavedPlane - The position, field, statistics and step history to store. On a plane that - keeps no field, ``plane.field`` and ``plane.noise_phase`` are ``None`` - (:class:`RetainingOutput`). - """ - ... - - def close(self) -> None: - """Finish the run, releasing any resource held. Calling it twice is harmless.""" - ... - - @property - def n_saved(self) -> int: # numpydoc ignore=RT01 - """How many planes have been stored.""" - ... - - @property - def metadata(self) -> RunMetadata | None: # numpydoc ignore=RT01 - """Record of the run, or ``None`` before it is opened.""" - ... - - @property - def stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The names of the statistics stored at every plane. - - The names, sorted; empty before the first plane is saved. - """ - ... - - @property - def step_stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The statistics stored between planes, if any. - - The names, sorted; empty when the run recorded no periodic subset. - """ - ... - - def __getitem__(self, key: str) -> np.ndarray: - """Return one stored array by name. - - Parameters - ---------- - key : str - ``'field'``, ``'z'``, ``'noise_phase'``, ``'field_planes'``, - ``'stats/'``, ``'step_history/'`` or ``'step_stats/'``. - - Returns - ------- - numpy.ndarray - The stored array, in the convention it is stored in. - """ - ... - - def __contains__(self, key: object) -> bool: - """Return whether ``self[key]`` would return an array, never raising. - - Parameters - ---------- - key : object - A candidate key. - - Returns - ------- - bool - ``True`` exactly when ``__getitem__`` would return an array for it. - """ - ... - - def fields(self, convention: Convention = "optics") -> np.ndarray: - """Return every stored field, in the requested sign convention. - - Parameters - ---------- - convention : {'optics', 'fft'}, optional - ``'optics'`` returns the arrays as stored; ``'fft'`` converts them back to - the internal convention a backend works in, for direct comparison with a - file stored in the FFT convention. - - Returns - ------- - numpy.ndarray - Shape ``(n_z, n_modes, n_pol, n_omega)``, complex. - """ - ... - - -@runtime_checkable -class CheckpointOutput(Protocol): - """Declare optional outcome and terminal-checkpoint persistence for a handler.""" - - def record_outcome( - self, outcome: RunOutcome, checkpoint: TerminalCheckpoint | None = None - ) -> None: - """Persist execution status and an optional accepted terminal field. - - Parameters - ---------- - outcome : RunOutcome - Cumulative work and completion status. - checkpoint : TerminalCheckpoint or None - Accepted field between regular saves. None retains a pending checkpoint - during progress and clears it when the run completes. - """ - ... - - @property - def outcome(self) -> RunOutcome | None: # numpydoc ignore=RT01 - """Recorded status, or None when no outcome was recorded.""" - ... - - @property - def terminal_checkpoint(self) -> TerminalCheckpoint | None: # numpydoc ignore=RT01 - """Latest accepted terminal field outside regular observations.""" - ... - - -@runtime_checkable -class RetainingOutput(Protocol): - """Declare that a handler accepts field-free planes, and is told the run's rule. - - Separate from :class:`Output`, as :class:`CheckpointOutput` is, because it is - optional: a handler that stores every field need not implement it, and a run that - keeps fewer than every field refuses a handler that does not - (``fields="planes"`` and ``fields="none"``, - :class:`~solprop.driver.simulation.SimulationSettings`). On a plane that keeps no - field, :meth:`Output.save` is given ``field=None`` and ``noise_phase=None``. - - Examples - -------- - >>> from solprop.output.schema import RetainingOutput - >>> class Spectra: - ... def retain(self, retention, field_shape): - ... self.keeps = retention.field_planes - >>> isinstance(Spectra(), RetainingOutput) - True - """ - - def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: - """Say which planes of the run keep their field, before the first is saved. - - The run loop (:func:`solprop.driver.run.run_problem`) calls it once per run, - after :meth:`Output.open` and the handler's ``reserve`` if it has one and - before any :meth:`Output.save`, and for ``fields="all"`` too. - - Parameters - ---------- - retention : FieldRetention - The rule, already :meth:`~FieldRetention.resolved` against the run's planes - (its indices count from the entrance, and none is negative). - field_shape : tuple of int - ``(n_modes, n_pol, n_omega)`` of the run's state, known here even when no - plane will carry a field. - - Raises - ------ - OutputError - A handler that already holds planes under a different rule raises this, - rather than mixing two rules in one record. - """ - ... - - -@dataclass(frozen=True) -class RecipeFile: - """A data file a recorded recipe refers to (SEP 41, S2). - - Attributes - ---------- - path : str - The path the recipe wrote, relative to its directory. - sha256 : str - Its SHA-256, as the recipe recorded it. - data : bytes or None - Its contents, embedded under the generated name it is filed by; ``None`` - when the run's files together passed the embedding cap, and only the path and - hash are kept. - """ - - path: str - sha256: str - data: bytes | None = None - - -@dataclass(frozen=True) -class RecipeRecord: - """The recipe a run was built from, as its output records it once (SEP 41, S2). - - Provenance outside the run record and its hash: a file without one is a file from - a script, and a reader that does not know it ignores it. - - Attributes - ---------- - format : int - The recipe format. - authored : str - The recipe's text as loaded, or its canonical TOML if it was edited. - resolved : str - The recipe to rebuild the run from: the authored keys with the drawn seed, the - keywords whose value written back gives the same record, at the values used, - and the execution settings at run start. - files : collections.abc.Mapping - The data files the recipe refers to, by the generated name each is filed by. - - Examples - -------- - >>> from solprop.output.schema import RecipeRecord - >>> record = RecipeRecord(1, "[recipe]", "[recipe]", {}) - >>> record.authored_sha256 == record.resolved_sha256 - True - """ - - format: int - authored: str - resolved: str - files: Mapping[str, RecipeFile] = dataclasses.field(default_factory=dict) - - @property - def authored_sha256(self) -> str: # numpydoc ignore=RT01 - """SHA-256 of the authored text, in hexadecimal.""" - return hashlib.sha256(self.authored.encode("utf-8")).hexdigest() - - @property - def resolved_sha256(self) -> str: # numpydoc ignore=RT01 - """SHA-256 of the resolved text, in hexadecimal.""" - return hashlib.sha256(self.resolved.encode("utf-8")).hexdigest() - - -def stored_spectrum( - field: Complex[np.ndarray, "*shape n_omega"], - kind: fourier.GridKind, - out: Complex[np.ndarray, "*shape n_omega"] | None = None, -) -> Complex[np.ndarray, "*shape n_omega"]: - """Return :func:`solprop.fourier.spectrum_to_optics` of a large field, in threads. - - One thread's conjugation and sign flip cost more than the state's whole crossing - from a device, and a state's rows are independent. The bits are the same. - - Parameters - ---------- - field : numpy.ndarray - A contiguous spectrum in the internal convention. - kind : {'field', 'envelope'} - The grid's spectral layout. - out : numpy.ndarray or None, optional - Contiguous room to write it to; a new array by default. - - Returns - ------- - numpy.ndarray - `out`, holding the spectrum in the optics convention. - """ - target = np.empty_like(field) if out is None else out - rows = field.reshape(-1, field.shape[-1]) - written = target.reshape(-1, field.shape[-1]) - - def _convert(selected: slice) -> None: - """Convert the selected rows into their room.""" - fourier.spectrum_to_optics(rows[selected], kind=kind, out=written[selected]) - - parallel_rows(rows.shape[0], field.nbytes, _convert) - return target - - -_EXPORT_HOOKS = ("save", "_append", "_stored_field") -"""The plane store methods that see a plane's field, whose owners must vouch for the -exported path, where a kept plane reaches them without one -(``PlaneStore.takes_exports``).""" - - -def _takes_exports_by_mro(cls: type) -> bool: - """Return whether a plane store class takes exported fields, by its MRO. - - The nearest class that declares ``_takes_exports`` in its own body and says - ``False`` opts the class out. Otherwise each of :data:`_EXPORT_HOOKS` is resolved - as a call would resolve it, and the class that defines it must be the base store - or declare ``_takes_exports = True`` in its own body: a ``save`` or a field hook - from a subclass or a mixin that does not vouch for the exported path keeps the - class on ``save``, which is given every plane with its field. - """ - mro = cls.__mro__ - declared = next((klass for klass in mro if "_takes_exports" in vars(klass)), None) - if declared is not None and vars(declared)["_takes_exports"] is not True: - return False - for name in _EXPORT_HOOKS: - owner = next((klass for klass in mro if name in vars(klass)), None) - if owner is None or owner is PlaneStore: - continue - if vars(owner).get("_takes_exports") is not True: - return False - return True - - -class PlaneStore(Displayed, ABC): - """The shared half of an output handler: one save protocol for every backing store. - - :class:`solprop.output.memory.MemoryOutput` and - :class:`solprop.output.hdf5.HDF5Output` differ only in where the arrays go. What - they must not differ in is *what* goes there: the sign convention of the stored - field, the metadata overwrite rule, the key names and the step-history layout. Those - live here, once, which is what makes the two handlers store a run bitwise - identically, with one overwrite rule and one growth strategy. - - The base class also holds the run's **layout** -- the field's shape, the statistic - names and whether a noise phase is stored -- fixed by the first plane saved and - checked against every later one, so that a plane that does not fit is refused by - name in either handler, rather than being dropped or raising deep inside the writer. - - A subclass implements four hooks: ``_write_metadata``, ``_append``, ``_read`` and - ``_release``, and may implement a fifth, ``_write_annotation``, to persist the - post-run annotations (:meth:`annotate`) a store held only in memory otherwise. - - A driver that knows how many planes the run will save says so with - :meth:`reserve`, and each store sizes its first allocation for them; a run that - saves more grows past it, so the number is a hint, never a limit. - - A run's session exports a kept field straight into the stored form (SEP 47 - section 5.4): :meth:`field_destination` gives the room to fill, a store's own - (`MemoryOutput`) or none, for a new array (`HDF5Output`), and - :meth:`save_exported` takes the plane, so that the field is converted once and, - in memory, written once, where it is kept. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import Output, PlaneStore - >>> handler = MemoryOutput() - >>> isinstance(handler, PlaneStore), isinstance(handler, Output) - (True, True) - """ - - _takes_exports: ClassVar[bool] = True - """Declared in a class's own body: whether its own ``save``, ``_append`` and - ``_stored_field`` keep to the exported path's contract (:attr:`takes_exports`).""" - - _exports: ClassVar[bool] = True - """Whether a run stores kept fields here through :meth:`save_exported`, resolved - once per class from its method resolution order (:func:`_takes_exports_by_mro`).""" - - def __init_subclass__(cls, **kwargs: object) -> None: - """Resolve whether the subclass takes exported fields, by its MRO. - - Parameters - ---------- - **kwargs : object - Passed on to the next class. - """ - super().__init_subclass__(**kwargs) - cls._exports = _takes_exports_by_mro(cls) - - def __init__(self) -> None: - """Start with nothing stored and nothing opened.""" - self._metadata: RunMetadata | None = None - self._recipe: RecipeRecord | None = None - self._n_saved = 0 - self._planned = 0 - self._closed = False - self._layout: PlaneLayout | None = None - # Which planes keep their field, and the state's shape for a run that keeps - # none, told by the run before its first plane (`retain`). - self._retention = FieldRetention() - self._field_shape: tuple[int, ...] = () - self._annotations: dict[str, JsonValue] = {} - self._outcome: RunOutcome | None = None - self._terminal_checkpoint: TerminalCheckpoint | None = None - # The attempts and rows of the checkpoint a resume started from, which no - # regular plane holds yet: the next plane or checkpoint is preceded by them. - self._carried: SavedPlane | None = None - self._resume_outcome_pending = False - - @property - def outcome(self) -> RunOutcome | None: # numpydoc ignore=RT01 - """Execution outcome, or None when none was recorded. - - The last recorded completion state and cumulative ledgers. - """ - return self._outcome - - @property - def terminal_checkpoint(self) -> TerminalCheckpoint | None: # numpydoc ignore=RT01 - """Terminal checkpoint without treating it as a saved plane. - - A field in the internal FFT convention and output amplitude units. - """ - return self._terminal_checkpoint - - def record_recipe(self, record: RecipeRecord) -> None: - """Record the recipe the run was built from, unless one is recorded already. - - Written once, at the first run start, and never rewritten (SEP 41, S2): a - later session of the same file, from a script or from an edited recipe, - leaves the first session's recipe in place and is recorded in its own - execution record. - - Parameters - ---------- - record : RecipeRecord - The authored and resolved texts and the data files. - """ - self._check_usable() - if self.recipe is not None: - return - self._write_recipe(record) - self._recipe = record - - def _write_recipe(self, record: RecipeRecord) -> None: - """Persist the recipe; a store that lives in memory has nothing to do.""" - - @property - def recipe(self) -> RecipeRecord | None: # numpydoc ignore=RT01 - """The recipe the run was built from, or ``None`` for a run from a script.""" - return self._recipe - - def record_outcome( - self, outcome: RunOutcome, checkpoint: TerminalCheckpoint | None = None - ) -> None: - """Persist status and optional terminal field without extending regular saves. - - Parameters - ---------- - outcome : RunOutcome - Current status and cumulative call ledgers. - checkpoint : TerminalCheckpoint or None - Accepted field, with no newly requested atomic statistics. With a - ``"running"`` outcome it is a running checkpoint (SEP 47 section 5.5): an - accepted endpoint between saved planes, written so that a snapshot or a - later failure has a state to resume from. Its attempts and periodic rows - are not consumed: the next regular plane still carries them, and saving - that plane supersedes the checkpoint. ``None`` keeps a pending checkpoint - during progress and clears it otherwise; passing back the checkpoint - held (:attr:`~PlaneStore.terminal_checkpoint`), or a copy, with a closing - outcome keeps it as the state to resume from. - - Raises - ------ - OutputError - If the handler is closed or holds no metadata, or the checkpoint - disagrees with the outcome or stored field layout. - """ - self._check_usable() - self._require_metadata() - previous = self._outcome - if previous is not None: - history = previous.previous_executions - if self._resume_outcome_pending or ( - previous.status != "running" and outcome.status == "running" - ): - closing = dataclasses.asdict(previous) - closing.pop("previous_executions") - history = (*history, closing) - outcome = _amended_outcome( - outcome, - previous_executions=tuple( - cast("dict[str, object]", _json_copy(dict(record))) - for record in history - ), - ) - if checkpoint is not None: - if checkpoint.plane.z != outcome.z: - raise OutputError("checkpoint and outcome positions must agree") - if ( - self._layout is not None - and self._layout.field_shape - and checkpoint.plane.shape != self._layout.field_shape - ): - raise OutputError("checkpoint field shape differs from saved planes") - if self._n_saved and checkpoint.plane.z < self._last_saved_z(): - raise OutputError("checkpoint precedes the last regular saved plane") - if self._carried is not None and not _holds_carried( - checkpoint.plane, self._carried - ): - # A new checkpoint is preceded by what a resume carried; the held one, - # or a copy of it, holds it already. Nothing is consumed: a running - # checkpoint is superseded, and the next plane is preceded by the - # same attempts. - checkpoint = dataclasses.replace( - checkpoint, - plane=_prepend_checkpoint_history(self._carried, checkpoint.plane), - ) - elif outcome.status == "running": - checkpoint = self._terminal_checkpoint - self._write_outcome(outcome, checkpoint) - self._outcome = outcome - self._resume_outcome_pending = False - self._terminal_checkpoint = checkpoint - if outcome.status != "running": - self._carried = None - - def _last_saved_z(self) -> float: - """Return the position of the last regular saved plane. - - - Returns: the position, m. - """ - return float(self["z"][-1]) - - def _write_outcome( - self, outcome: RunOutcome | None, checkpoint: TerminalCheckpoint | None - ) -> None: - """Persist records; the memory store needs no additional storage hook. - - - ``checkpoint``: accepted checkpoint, or None to clear an older one. - """ - - @property - def annotations(self) -> Mapping[str, JsonValue]: # numpydoc ignore=RT01 - """Post-run annotations the store holds, by name. - - An annotation is something learned only once the run has closed -- the - material manifest's peak-field report (:data:`PEAK_FIELDS_ANNOTATION`) is - one -- so it is not part of the run's record: the configuration hash, a - resume and a scan's reuse never see it. A store without any, a file written - before annotations existed included, answers with an empty mapping. - - Each annotation's plain JSON values, by name; a copy. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> dict(MemoryOutput().annotations) - {} - """ - return cast("dict[str, JsonValue]", json.loads(json.dumps(self._annotations))) - - def annotate(self, name: str, value: object) -> None: - """Record a post-run annotation, replacing any of the same name. - - The run's record is not touched: an annotation stays outside the - configuration hash, so a file annotated after its run is still the same run - to :func:`solprop.output.hdf5.check_cache` and to a scan. A store may be - annotated after it is closed; a file is then reopened briefly to write it. - - Parameters - ---------- - name : str - A non-empty name without ``/``, such as :data:`PEAK_FIELDS_ANNOTATION`. - value : object - Plain JSON values: mappings with string keys, lists, strings, numbers, - booleans, None; anything else is refused here, the store's boundary. - - Raises - ------ - OutputError - If the store was never opened, the name is empty or holds ``/``, or the - value is not plain JSON. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import _example_metadata - >>> handler = MemoryOutput() - >>> handler.open(_example_metadata()) - >>> handler.annotate("note", {"peak": 1.5}) - >>> handler.annotations["note"] - {'peak': 1.5} - """ - self._require_metadata() - if not name or "/" in name: - msg = ( - f"an annotation is named by a non-empty name without '/', got {name!r}" - ) - raise OutputError(msg) - try: - text = json.dumps(value, sort_keys=True, allow_nan=False) - except (TypeError, ValueError) as error: - msg = f"annotation {name!r} is not plain JSON: {error}" - raise OutputError(msg) from error - self._write_annotation(name, text) - self._annotations[name] = cast("JsonValue", json.loads(text)) - - def _write_annotation(self, name: str, text: str) -> None: - """Persist one annotation; a store that lives in memory has nothing to do.""" - - @property - def metadata(self) -> RunMetadata | None: # numpydoc ignore=RT01 - """Record of the run, or ``None`` before it is opened.""" - return self._metadata - - @property - def n_saved(self) -> int: # numpydoc ignore=RT01 - """How many planes have been stored.""" - return self._n_saved - - @property - def closed(self) -> bool: # numpydoc ignore=RT01 - """Whether the handler has been closed.""" - return self._closed - - @property - def stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The names of the statistics stored at every plane. - - Read off the layout the first plane fixed, so that the two handlers answer - alike, and a file continued or opened for reading answers from what it holds. - - The names, sorted; empty before the first plane is saved. - """ - return () if self._layout is None else self._layout.stat_names - - @property - def step_stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The statistics the between-planes channel carries. - - Empty for a run that asked for no periodic subset, which is how a reader - tells whether the channel is there to prefer over the planes. - - The names, sorted; empty when the run has no channel. - """ - return () if self._layout is None else self._layout.step_stat_names - - def _state_text(self) -> str: - """Return whether the store is open, for printing. - - - Returns: ``"open"`` or ``"closed"``. - """ - return "closed" if self._closed else "open" - - def _span_text(self) -> str: - """Return the positions the saved planes span, for printing.""" - if not self._n_saved: - return "no plane saved yet" - try: - z = np.asarray(self["z"], dtype=float) - except OutputError: - return "positions unavailable while the store is closed" - return f"{engineering(float(z[0]), 'm')} to {engineering(float(z[-1]), 'm')}" - - def __repr__(self) -> str: - """Return a one-line description: how much is stored, and whether it is open. - - Returns - ------- - str - For example ``MemoryOutput(201 planes, 2 modes, closed)``. - """ - fields: list[tuple[str, object]] = [("", f"{self._n_saved} planes")] - if self._layout is not None: - fields.append(("", "x".join(str(n) for n in self._layout.field_shape))) - fields.append(("", self._state_text())) - return line(type(self).__name__, fields) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - rows: list[tuple[str, object]] = [ - ("state", self._state_text()), - ("planes", f"{self._n_saved}, {self._span_text()}"), - ] - if self._layout is not None: - shape = " x ".join(str(n) for n in self._layout.field_shape) - rows.append(("field per plane", f"{shape} (modes x components x omega)")) - rows.append(("statistics", ", ".join(self._layout.stat_names) or "none")) - between = ", ".join(self._layout.step_stat_names) or "none" - rows.append(("between planes", between)) - if self._metadata is not None: - rows.append(("run", repr(self._metadata))) - return type(self).__name__, rows - - def __enter__(self) -> "PlaneStore": - """Return the handler, so that a ``with`` block closes it on the way out. - - Returns - ------- - PlaneStore - This handler. - """ - return self - - def __exit__(self, *details: object) -> None: - """Close the handler when the block ends, whatever happened inside it. - - Parameters - ---------- - *details : object - The exception being raised, if any; it is not suppressed. - """ - self.close() - - def open(self, metadata: RunMetadata, *, force: bool = False) -> None: - """Begin a run, recording what produced it. - - Parameters - ---------- - metadata : RunMetadata - The record of the run about to start. - force : bool, optional - Whether to overwrite metadata already held for a different run. Without it - a second, different record raises; with it the record is replaced and the - replacement logged (:func:`resolve_metadata_write`). - - Raises - ------ - OutputError - If the handler is closed, or a different record is already held and - ``force`` is false. - """ - self._check_usable() - if resolve_metadata_write(self._metadata, metadata, force=force): - self._write_metadata(metadata) - self._metadata = metadata - - def save(self, plane: SavedPlane) -> None: - """Store one saved plane, converting its field to the optics convention. - - Parameters - ---------- - plane : SavedPlane - The position, field, statistics and step history to store. Its field is in - the internal FFT convention and is written out as - :func:`solprop.fourier.spectrum_to_optics` of it; a field-free plane - stores none. - - Raises - ------ - OutputError - If the handler is closed or was never opened, or the plane does not fit - the run's layout: a field where the run keeps none, none where it keeps - one (:meth:`retain`), another shape or other statistics. - """ - self._store(plane, None) - - @property - def takes_exports(self) -> bool: # numpydoc ignore=RT01 - """Whether a run hands this store its kept fields already stored. - - A store that takes them is given each kept plane through - :meth:`save_exported`, its field exported by the run's session straight into - the stored form, and into :meth:`field_destination` (SEP 47 section 5.4). A - subclass whose ``save``, ``_append`` or ``_stored_field`` comes from a class - that does not vouch for the exported path -- its own override, or a mixin's - ahead of the store in its method resolution order -- does not: it sees every - plane, and its field, through ``save`` as before. A class vouches with - ``_takes_exports = True`` in its own body, and opts out with ``False``. - - Whether :meth:`save_exported` is how the run stores kept fields here. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> MemoryOutput().takes_exports - True - """ - return self._exports - - def field_destination( - self, plane: SavedPlane, shape: tuple[int, ...] - ) -> Complex[np.ndarray, "n_modes n_pol n_omega"] | None: - """Return where the next plane's field is to be exported, or ``None``. - - The run's service asks before it exports a kept field (SEP 47 section 5.4). A - store with room of its own for the field returns that room, a writeable, - C-contiguous complex128 array of `shape`, and the export fills it once in the - stored form, the true-power amplitude (or a spatial route's field measure) in - the optics convention; :meth:`save_exported` then takes the plane. The room is - the store's: no other plane is given it and nothing writes to it again once the - plane is stored. A store without such room returns ``None`` and is handed a new - array, which it takes as its own. - - Parameters - ---------- - plane : SavedPlane - The plane about to be stored, without its field: its statistics fix the - layout of a store's first allocation. - shape : tuple of int - ``(n_modes, n_pol, n_omega)`` of the field. - - Returns - ------- - numpy.ndarray or None - The room, or ``None`` for a new array. This base class has none. - - Raises - ------ - OutputError - If the handler is closed or was never opened, the run keeps no field at - this plane (:meth:`retain`), or `shape` is not the run's field shape. - """ - self._check_destination(plane, shape) - return None - - def save_exported(self, plane: SavedPlane, stored: StoredField) -> None: - """Store one saved plane whose field was exported already in its stored form. - - The run's service calls it instead of :meth:`save` when the store - :attr:`takes_exports`; what is stored is what :meth:`save` stores for the same - field, since the export's conversion is the one :meth:`save` makes. - - Parameters - ---------- - plane : SavedPlane - The position, statistics and step history to store, with no field of its - own. - stored : StoredField - Its field in the stored form, in the room :meth:`field_destination` - returned or in any other array, which is copied in where the store keeps - fields in its own room; and its noise phase. - - Raises - ------ - OutputError - If `plane` carries a field, or for any reason :meth:`save` refuses a - plane. - """ - if plane.field is not None: - msg = ( - "a plane whose field was exported is stored without a field of its " - "own; save() takes a plane with its field" - ) - raise OutputError(msg) - self._store(plane, stored) - - def _check_destination(self, plane: SavedPlane, shape: tuple[int, ...]) -> None: - """Refuse to give room for a plane that cannot be stored with a field. - - - ``shape``: the field's shape. - """ - self._check_usable() - self._require_metadata() - if plane.field is not None: - msg = "a plane exported into a store's room carries no field of its own" - raise OutputError(msg) - index = self._n_saved - if not self._retention.keeps(index): - msg = ( - f"plane {index} keeps no field: the run was saved with " - f"{self._retention.describe()}" - ) - raise OutputError(msg) - held = ( - self._layout.field_shape - if self._layout is not None and self._fields_stored() - else self._field_shape - ) - if held and tuple(shape) != tuple(held): - msg = ( - f"plane {index} has field shape {tuple(shape)}, but the run stores " - f"{tuple(held)}" - ) - raise OutputError(msg) - - def _store(self, plane: SavedPlane, exported: StoredField | None) -> None: - """Store one plane, its field taken from the plane or already exported. - - - ``exported``: its field in the stored form (:meth:`save_exported`), or - ``None`` to convert the plane's own field. - """ - self._check_usable() - metadata = self._require_metadata() - if self._carried is not None: - plane = _prepend_checkpoint_history(self._carried, plane) - if exported is not None and exported.noise_phase is not None: - plane = _with_phase(plane, exported.noise_phase) - self._check_layout(plane, plane.field if exported is None else exported.values) - if exported is not None: - stored = self._take_exported(plane, exported.values) - else: - stored = ( - None - if plane.field is None - else self._stored_field(plane, metadata.grid.kind) - ) - self._append(plane, stored) - self._n_saved += 1 - self._carried = None - if self._terminal_checkpoint is not None: - # A terminal or running checkpoint is superseded by the plane beyond it - # (SEP 47 section 5.5): a resume never goes back to it. - self._write_outcome(self._outcome, None) - self._terminal_checkpoint = None - - def _take_exported( - self, - plane: SavedPlane, - exported: Complex[np.ndarray, "n_modes n_pol n_omega"], - ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return an exported field as the store holds it; this base class takes it. - - A store that keeps fields in a room of its own copies a field exported - elsewhere into it (`MemoryOutput`). - """ - del plane - return exported - - def reserve(self, n_planes: int) -> None: - """Expect a run of `n_planes` saved planes in all, before the first is saved. - - A store sizes its plane-indexed arrays for this many planes when the first - plane fixes their shapes, rather than growing them plane by plane; a run that - saves more grows them past it (by doubling), and one that saves fewer uses - only what it saved, so the count is a hint and changes nothing that is - stored. The run loop (:func:`solprop.driver.run.run_problem`) reserves the - entrance plane and every save plane. A later call can only raise the count. - - Parameters - ---------- - n_planes : int - The planes the run will save, the entrance included; non-negative. - - Raises - ------ - OutputError - If `n_planes` is negative. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> handler = MemoryOutput() - >>> handler.reserve(201) - >>> handler.n_saved - 0 - """ - if n_planes < 0: - msg = f"a store reserves a non-negative number of planes, got {n_planes}" - raise OutputError(msg) - self._planned = max(self._planned, int(n_planes)) - - def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: - """Say which planes of the run keep their field, before the first is saved. - - The run loop (:func:`solprop.driver.run.run_problem`) calls it with the run's - resolved rule. A store then refuses a plane that breaks the rule, sizes its - field storage for the planes that keep one, and answers field reads on the - others by naming the rule. A store continuing a run (a resumed file) already - holds a rule and accepts only the same one. - - Parameters - ---------- - retention : FieldRetention - The rule, :meth:`~FieldRetention.resolved` against the run's planes. - field_shape : tuple of int - ``(n_modes, n_pol, n_omega)`` of the run's state, which a run that keeps no - field records in its place. - - Raises - ------ - OutputError - If the store already holds planes under another rule. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import FieldRetention - >>> handler = MemoryOutput() - >>> handler.retain(FieldRetention("none"), (1, 1, 8)) - >>> handler.retention.fields, handler.field_shape - ('none', (1, 1, 8)) - """ - if self._n_saved and retention != self._retention: - msg = ( - f"this output holds planes saved with {self._retention.describe()}, " - f"and a run continuing it asks for {retention.describe()}; a run keeps " - "its fields by one rule, so continue it with the same fields and " - "field_planes" - ) - raise OutputError(msg) - self._retention = retention - self._field_shape = tuple(int(size) for size in field_shape) - - @property - def retention(self) -> FieldRetention: # numpydoc ignore=RT01 - """Which planes of the run keep their field. - - The rule the run declared (:meth:`retain`), every plane by default. - """ - return self._retention - - @property - def field_shape(self) -> tuple[int, ...]: # numpydoc ignore=RT01 - """Shape of one plane's field, stored or not. - - ``(n_modes, n_pol, n_omega)``; empty before anything has said it. - """ - if self._layout is not None and self._layout.field_shape: - return self._layout.field_shape - return self._field_shape - - def close(self) -> None: - """Finish the run, releasing whatever the store holds open. - - Calling it a second time does nothing, so a driver may close in a ``finally`` - and a caller may close again without checking. - """ - if self._closed: - return - self._release() - self._closed = True - - def fields(self, convention: Convention = "optics") -> np.ndarray: - """Return every stored field, in the requested sign convention. - - Parameters - ---------- - convention : {'optics', 'fft'}, optional - ``'optics'`` returns the arrays exactly as stored; ``'fft'`` converts them - back to the internal convention a backend works in, so that a file stored in - the FFT convention can be compared with them bin by bin. - - Returns - ------- - numpy.ndarray - Shape ``(n_z, n_modes, n_pol, n_omega)``, complex. - - Raises - ------ - OutputError - If nothing has been stored yet, or the run did not keep the field on - every plane (:meth:`plane_field` reads a plane that kept one). - """ - stored = self["field"] - if convention == "fft": - kind = self._require_metadata().grid.kind - return fourier.spectrum_to_internal(stored, kind=kind) - return stored - - def __getitem__(self, key: str) -> np.ndarray: - """Return one stored array by name, as stored. - - Parameters - ---------- - key : str - ``'field'``, ``'z'``, ``'noise_phase'``, ``'stats/'``, - ``'step_history/'`` or ``'step_stats/'``. The step history is - concatenated over planes, with ``'step_history/counts'`` giving the number - of attempts belonging to each; the between-planes statistics are laid out - the same way, with ``'step_stats/z'``, ``'step_stats/step'`` and - ``'step_stats/counts'`` beside the named statistics, and read back empty - for a run that recorded none; ``'noise_phase'`` is there only when the run - carried a noise model. - - Returns - ------- - numpy.ndarray - The stored array; the field in the optics convention. - - Raises - ------ - OutputError - If the key is not one of those forms, or names something not stored; for - ``'field'`` and ``'noise_phase'``, also when the run did not keep the - field on every plane, naming its ``fields`` (:meth:`plane_field` reads a - plane that kept one). ``'field_planes'`` lists the saved planes that keep - their field, for every run. - """ - group, name = _split_key(key) - if group == "" and name == "field_planes": - return self._field_planes() - if group == "" and name in ("field", "noise_phase"): - self._refuse_partial_fields(name) - return self._read(group, name) - - def _field_planes(self) -> Int[np.ndarray, " n_kept"]: - """Return the indices of the saved planes that keep their field.""" - indices = range(self._n_saved) - return np.array( - [index for index in indices if self._retention.keeps(index)], dtype=np.int64 - ) - - def _refuse_partial_fields(self, name: str) -> None: - """Refuse a whole-run field read on a run that kept fields on some planes only. - - - ``name``: ``'field'`` or ``'noise_phase'``. - """ - if self._retention.fields == "all": - return - where = ( - "no plane" - if self._retention.fields == "none" - else "only the saved planes listed by output['field_planes']" - ) - msg = ( - f"this run was saved with {self._retention.describe()}: its {name} is " - f"stored on {where}, so there is no {name} for every plane to read; " - "read a plane that kept its field with plane_field(index), or rerun with " - "fields='all'" - ) - raise OutputError(msg) - - def plane_field(self, index: int, convention: Convention = "optics") -> np.ndarray: - """Return one saved plane's field, in the requested sign convention. - - The accessor that works whatever the run kept: a plane that kept its field - returns it, and one that did not is refused by name. - - Parameters - ---------- - index : int - The saved plane, the entrance as 0 and negative from the end. - convention : {'optics', 'fft'}, optional - As for :meth:`fields`. - - Returns - ------- - numpy.ndarray - Shape ``(n_modes, n_pol, n_omega)``, complex. - - Raises - ------ - OutputError - If the index is out of range, or that plane kept no field, naming the - run's ``fields``. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import SavedPlane, _example_metadata - >>> handler = MemoryOutput() - >>> handler.open(_example_metadata()) - >>> n_omega = handler.metadata.grid.n_omega - >>> handler.save(SavedPlane(z=0.0, field=np.ones((1, 1, n_omega), complex))) - >>> handler.plane_field(-1).shape - (1, 1, 2560) - """ - plane = index + self._n_saved if index < 0 else index - if not 0 <= plane < self._n_saved: - msg = ( - f"plane {index} is out of range for a run of {self._n_saved} saved " - "planes" - ) - raise OutputError(msg) - if not self._retention.keeps(plane): - msg = ( - f"saved plane {plane} kept no field: this run was saved with " - f"{self._retention.describe()}" - ) - raise OutputError(msg) - slot = ( - plane - if self._retention.fields == "all" - else self._retention.field_planes.index(plane) - ) - stored = self._read_field(slot) - if convention == "fft": - kind = self._require_metadata().grid.kind - return fourier.spectrum_to_internal(stored, kind=kind) - return stored - - def _read_field(self, slot: int) -> np.ndarray: - """Return one stored field by its row in the field storage. - - - ``slot``: the row: the plane's index in an ``"all"`` run, its position among - the kept planes otherwise. - - Returns: the stored field, optics convention. - """ - return self._read("", "field")[slot] - - def __contains__(self, key: object) -> bool: - """Return whether ``self[key]`` would return an array. - - Answers for exactly the keys :meth:`__getitem__` accepts, and never raises: - without it Python's fallback would iterate the handler with integer keys. - - Parameters - ---------- - key : object - A candidate key; anything but a string of one of :meth:`__getitem__`'s - forms is not held. - - Returns - ------- - bool - ``True`` when the handler holds the array; ``False`` for an unknown form, - a name not stored, nothing saved yet or a closed file. - """ - if not isinstance(key, str): - return False - # Asking the reader is the one way to agree with it exactly; the cost is one - # read of the array, which a membership test is followed by anyway. - try: - self[key] - except OutputError: - return False - return True - - def _stored_field( - self, plane: SavedPlane, kind: fourier.GridKind - ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return a plane's field as it is stored, in the optics convention. - - A handler with room for it already converts it there (`MemoryOutput`). - - - ``kind``: the grid's spectral layout. - """ - assert plane.field is not None - return stored_spectrum(plane.field, kind) - - def _check_layout( - self, - plane: SavedPlane, - stored: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, - ) -> None: - """Adopt the layout from the first plane, and refuse a later one that differs. - - - ``stored``: its field, in any convention, as only its shape is read; ``None`` - on a field-free plane. - """ - index = self._n_saved - retention = self._retention - if retention.keeps(index) != (stored is not None): - has = "carries no field" if stored is None else "carries a field" - msg = ( - f"plane {index} {has}, and the run was saved with " - f"{retention.describe()}; a run keeps its fields by one rule, declared " - "before its first plane (retain)" - ) - raise OutputError(msg) - layout = PlaneLayout( - field_shape=self._field_shape if stored is None else stored.shape, - stat_names=tuple(sorted(plane.stats)), - has_noise_phase=plane.noise_phase is not None, - step_stat_names=plane.step_stats.names, - retention=retention, - ) - if self._layout is None: - self._layout = layout - return - held = self._layout - # The entrance plane carries no rows, so the run's row-value names are learnt - # from the first plane that has any. - if not held.step_stat_names and layout.step_stat_names: - held = dataclasses.replace(held, step_stat_names=layout.step_stat_names) - self._layout = held - # Whether a noise phase is stored beside the field is learnt from the first - # plane that kept its field: a field-free plane carries none either way. - if stored is not None and not self._fields_stored(): - held = dataclasses.replace( - held, - field_shape=stored.shape, - has_noise_phase=layout.has_noise_phase, - ) - self._layout = held - if ( - stored is not None - and held.field_shape - and layout.field_shape != (held.field_shape) - ): - msg = ( - f"plane {index} has field shape {layout.field_shape}, but the run " - f"stores {held.field_shape}" - ) - raise OutputError(msg) - if layout.stat_names != held.stat_names: - msg = ( - f"plane {index} reports statistics {list(layout.stat_names)}, but the " - f"run stores {list(held.stat_names)}" - ) - raise OutputError(msg) - if stored is not None and layout.has_noise_phase is not held.has_noise_phase: - has = "carries" if layout.has_noise_phase else "does not carry" - msg = f"plane {index} {has} a noise phase, unlike those before it" - raise OutputError(msg) - # A plane may legitimately carry no rows -- the entrance plane never does, and - # at a period above one a short plane need not -- so the names are compared - # only where there are rows to name. - if layout.step_stat_names and layout.step_stat_names != held.step_stat_names: - msg = ( - f"plane {index} reports step statistics " - f"{list(layout.step_stat_names)}, but the run stores " - f"{list(held.step_stat_names)}" - ) - raise OutputError(msg) - - def _fields_stored(self) -> int: - """Return how many of the planes saved so far kept their field.""" - if self._retention.fields == "all": - return self._n_saved - return sum(1 for index in self._retention.field_planes if index < self._n_saved) - - def _check_usable(self) -> None: - """Refuse to write to a closed handler.""" - if self._closed: - msg = "this output handler is closed; open a new one to store another run" - raise OutputError(msg) - - def _require_metadata(self) -> RunMetadata: - """Return the run's metadata, refusing to work without it.""" - if self._metadata is None: - msg = "call open(metadata) before saving or reading a plane" - raise OutputError(msg) - return self._metadata - - @abstractmethod - def _write_metadata(self, metadata: RunMetadata) -> None: - """Commit the run's metadata to the backing store.""" - ... - - @abstractmethod - def _append( - self, - plane: SavedPlane, - stored: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, - ) -> None: - """Append one plane to the backing store. - - - ``plane``: the plane: its field in the internal convention, or ``None`` where - the field was exported straight into the stored form - (:meth:`~solprop.output.schema.PlaneStore.save_exported`) and `stored` alone - holds it. A subclass whose own ``_append`` reads the plane's field does not - take exports (``PlaneStore.takes_exports``). - - ``stored``: that field converted to the optics convention, which is what to - store; ``None`` on a field-free plane, which stores no field. - """ - ... - - @abstractmethod - def _read(self, group: str, name: str) -> np.ndarray: - """Return one stored array. - - - ``group``: ``''`` for a top-level array, else ``'stats'`` or - ``'step_history'``. - """ - ... - - @abstractmethod - def _release(self) -> None: - """Release whatever the store holds open. Called once, by :meth:`close`.""" - ... - - -def _split_key(key: str) -> tuple[str, str]: - """Split an accessor key into its group and its name. - - - ``key``: ``'field'``, ``'z'``, ``'noise_phase'``, ``'field_planes'``, - ``'stats/'``, ``'step_history/'`` or ``'step_stats/'``. - - Returns: ``(group, name)``, the group empty for a top-level array. - """ - if key in ("field", "z", "noise_phase", "field_planes"): - return ("", key) - group, _, name = key.partition("/") - if group in ("stats", "step_history", "step_stats") and name: - return (group, name) - msg = ( - f"unknown key {key!r}; a handler holds 'field', 'z', 'noise_phase', " - "'field_planes', 'stats/', 'step_history/' and 'step_stats/'" - ) - raise OutputError(msg) diff --git a/src/solprop/output/serialisation.py b/src/solprop/output/serialisation.py new file mode 100644 index 00000000..1e38e3c8 --- /dev/null +++ b/src/solprop/output/serialisation.py @@ -0,0 +1,609 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Serialisation of configuration objects into the JSON a run record stores. + +:func:`serialise` turns any configuration object into JSON primitives, dropping nothing: +what it cannot reduce it records by ``repr`` and marks irreproducible. A large numeric +array becomes a reference to its bytes (:data:`RECORD_ARRAY_LIMIT`), and a large array +input is recorded by its :class:`ArrayDigest` or by a :class:`StatedInput` +(:func:`recorded_input`). The private helpers here read a stored record back +(``_rebuild``, ``_resolved``) for the record types of :mod:`solprop.output.records` and +:mod:`solprop.output.metadata`. + +The manual page is ``docs/guide/results/provenance.md``. +""" + +import dataclasses +import hashlib +import json +import logging +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +import numpy as np +from scipy.interpolate import CubicSpline + +from solprop.exceptions import OutputError +from solprop.modes.material import JsonValue + +__all__ = [ + "INLINE_ARRAY_LIMIT", + "RECORD_ARRAY_LIMIT", + "ArrayDigest", + "JsonValue", + "StatedInput", + "recorded_input", + "serialise", +] + +logger = logging.getLogger(__name__) + +# JsonValue is imported above and re-exported here, where the file format is +# owned. It is defined in solprop.modes.material because the record types import +# solprop.modes.protocol: defining it here and importing it there would close a +# cycle through the output layer. + +RECORD_ARRAY_LIMIT = 4096 +"""The most elements a numeric array of a run record holds as JSON (#309). + +A larger one -- a per-node weight, a flux plan's coefficients -- is recorded by a +reference holding its digest, dtype and shape, and its bytes are stored beside the +record, named by that digest +(:meth:`~solprop.output.metadata.RunMetadata.record_arrays`). So the hash takes the +array's bytes rather than its text, which costs milliseconds rather than seconds, and +the array stays readable. The limit keeps a grid's axes in the text, for a person +reading it. +""" + +_RECORD_ARRAY = "__record_array__" +"""The key of a reference to an array stored beside the record.""" + +INLINE_ARRAY_LIMIT = 2**20 +"""The most elements an array input is recorded with inline (SEP 22, Q4). + +A larger array -- a gigabyte spatial launch -- is recorded by an :class:`ArrayDigest` +of its bytes, dtype and shape instead, which costs seconds of hashing per input rather +than tens of gigabytes of JSON, and still changes with any one sample. A caller may +state its identity instead, with a :class:`StatedInput`. +""" + + +def _tag_unreproducible(value: object, key: str) -> dict[str, JsonValue]: + """Return the record of an object that cannot be rebuilt from the file. + + - ``key``: tag naming why it is irreducible, ``'__callable__'`` or ``'__repr__'``. + - Returns: a record holding the object's ``repr`` and ``reproducible: False``. + """ + logger.debug("configuration entry recorded by repr only: %s", key) + return {key: repr(value), "reproducible": False} + + +def _serialise_dataclass(value: object) -> dict[str, JsonValue]: + """Return a dataclass instance as a tagged dictionary of its constructor fields. + + Fields declared ``init=False`` are derived in ``__post_init__`` from the ones that + are, so recording them would store the same information twice and make two runs + that differ in nothing hash differently if a derived quantity ever changes form. + + A field whose metadata says ``record=False`` is a live handle that the record + carries elsewhere, and is left out for the same reason: a provider channel's + :class:`~solprop.modes.guide.ChannelSource` holds the provider object, whose + identity the run records once beside its channels. + + - Returns: ``{'__class__': 'module.QualName', : , ...}``. + """ + kind = type(value) + name = f"{kind.__module__}.{kind.__qualname__}" + record: dict[str, JsonValue] = {"__class__": name} + # The parameter stays `object` because the test session resolves annotations at + # run time, and `fields` wants typeshed's `DataclassInstance`, which does not exist + # there; the caller has already checked that this is a dataclass instance. + for entry in dataclasses.fields(value): # type: ignore[arg-type] - checked by the caller + if not entry.init or not entry.metadata.get("record", True): + continue + record[entry.name] = serialise(getattr(value, entry.name)) + return record + + +def _serialise_enum(value: Enum) -> dict[str, JsonValue]: + """Return an enumeration member as its qualified class name and its value. + + The class matters as much as the value: two enumerations may share a member value + and mean different things, and the configuration hash has to tell them apart. + + - Returns: ``{'__enum__': 'module.QualName', 'value': }``. + """ + kind = type(value) + return { + "__enum__": f"{kind.__module__}.{kind.__qualname__}", + "value": serialise(value.value), + } + + +class _ArrayReference(dict): + """A reference to a record array, in its JSON form, carrying the array it names. + + A serialised record is JSON-ready, so a reference is a plain mapping to :mod:`json`, + and the array travels with it until the record is stored + (:meth:`~solprop.output.metadata.RunMetadata.record_arrays`). + + Attributes + ---------- + array : numpy.ndarray + The array. + """ + + def __init__(self, values: np.ndarray) -> None: + """Digest the array and hold it.""" + digest = ArrayDigest.of(values) + super().__init__( + { + _RECORD_ARRAY: _array_name(digest), + "dtype": digest.dtype, + "shape": list(digest.shape), + } + ) + self.array = values + + +def _serialise_numpy(value: np.ndarray | np.generic) -> JsonValue: + """Return a NumPy array as a nested list with its dtype, or a scalar as a scalar. + + A complex array is split into its real and imaginary parts, because JSON has no + complex number and a nested list of them would not survive a round trip. A numeric + array of more than :data:`RECORD_ARRAY_LIMIT` elements is a reference instead. + + - Returns: a ``__ndarray__`` record for an array, and the plain Python value of a + scalar. + """ + if isinstance(value, np.generic): + return serialise(value.item()) + if value.size > RECORD_ARRAY_LIMIT and value.dtype.kind in "biufc": + return _ArrayReference(value) + return _inline_array(value) + + +def _inline_array(value: np.ndarray) -> dict[str, JsonValue]: + """Return an array as a ``__ndarray__`` record, whatever its size. + + - Returns: its dtype, its shape and its values. + """ + record: dict[str, JsonValue] = { + "dtype": str(value.dtype), + "shape": list(value.shape), + } + if np.iscomplexobj(value): + record["__ndarray__"] = np.real(value).tolist() + record["__imaginary__"] = np.imag(value).tolist() + else: + # tolist gives JSON primitives for every numeric and boolean dtype; anything + # else goes back through serialise, which records what it cannot reduce. + record["__ndarray__"] = serialise(value.tolist()) + return record + + +def _inlined(value: JsonValue) -> JsonValue: + """Return a serialised structure with each array reference's values in its text. + + For text with no store beside it, which has to stand alone: only a run record keeps + its arrays beside its text + (:meth:`~solprop.output.metadata.RunMetadata.record_arrays`). A reference already + read back from text carries no array and stays as it is. + """ + if isinstance(value, _ArrayReference): + return _inline_array(value.array) + if isinstance(value, dict): + return {key: _inlined(item) for key, item in value.items()} + if isinstance(value, list): + return [_inlined(item) for item in value] + return value + + +def _serialise_sequence(value: object) -> list[JsonValue]: + """Return a sequence or a set as a list, ordering set members deterministically. + + A set's members are sorted by their canonical JSON text, so that the configuration + hash does not depend on set iteration order. + """ + # `object` is not declared iterable; the caller has already checked that this is + # a set or a sequence. + members = [serialise(item) for item in value] # type: ignore[union-attr] - checked by the caller + if isinstance(value, set | frozenset): + return sorted(members, key=_canonical_json) + return members + + +def _serialise_object(value: object) -> JsonValue: + """Return a structured or opaque object as a JSON-safe structure.""" + if isinstance(value, Path): + return str(value) + if type(value) is CubicSpline: + # Material tables own these interpolants. Their repr contains an address, + # so record the complete polynomial instead of an irreproducible callable. + return { + "__class__": "scipy.interpolate.CubicSpline", + "x": serialise(value.x), + "c": serialise(value.c), + "axis": serialise(value.axis), + "extrapolate": serialise(value.extrapolate), + } + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return _serialise_dataclass(value) + if isinstance(value, Mapping): + return {str(key): serialise(item) for key, item in value.items()} + if isinstance(value, set | frozenset | Sequence) and not isinstance( + value, str | bytes + ): + return _serialise_sequence(value) + return _tag_unreproducible(value, "__callable__" if callable(value) else "__repr__") + + +def serialise(value: object) -> JsonValue: + """Return any configuration object as a JSON-safe structure, dropping nothing. + + Dataclasses become tagged dictionaries of their constructor fields (less a live + handle whose field metadata says ``record=False``), enumerations + their qualified name and value, NumPy arrays a nested list with their dtype and + shape, or above :data:`RECORD_ARRAY_LIMIT` elements a reference to their bytes, + cubic splines their knots, coefficients, axis and extrapolation setting, + complex numbers a real/imaginary pair, and mappings and sequences are + recursed. Anything that cannot be reduced -- a function, a lambda, an object with + no structure -- is recorded as its ``repr`` and marked ``reproducible: False``, + because a configuration that is silently incomplete is worse than one that says + where it stops. + + The function is idempotent: applying it to its own output returns that output, so a + round-tripped :class:`~solprop.output.metadata.RunMetadata` hashes exactly as the + original does. + + Parameters + ---------- + value : object + Anything at all. + + Returns + ------- + NoneType or bool or int or float or str or list or dict + A structure of JSON primitives, lists and string-keyed dictionaries. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.serialisation import serialise + >>> serialise({"radius": 125e-6}) + {'radius': 0.000125} + >>> serialise(np.array([1.0, 2.0]))["dtype"] + 'float64' + >>> serialise(abs)["reproducible"] + False + """ + if value is None or isinstance(value, bool | _ArrayReference): + return value + if isinstance(value, Enum): + return _serialise_enum(value) + if isinstance(value, int | float | str): + return value + if isinstance(value, complex): + return {"__complex__": [value.real, value.imag]} + if isinstance(value, np.ndarray | np.generic): + return _serialise_numpy(value) + return _serialise_object(value) + + +_DIGEST_BLOCK = 2**16 +"""Elements a digest of an array not held in its order reads at a time.""" + + +@dataclass(frozen=True) +class ArrayDigest: + """An array recorded by the digest of its contents rather than inline. + + What a run record holds for an array input above :data:`INLINE_ARRAY_LIMIT` + elements (SEP 22 section 5.2, Q4): the SHA-256 of its bytes in little-endian order, + with its dtype and shape. Two inputs that differ in one sample record different + digests, so their runs have different configuration hashes; the array itself is + not recoverable from the record. + + Attributes + ---------- + sha256 : str + Sixty-four lowercase hexadecimal characters. + dtype : str + The array's dtype, as NumPy names it (``"complex128"``). + shape : tuple of int + The array's shape. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.serialisation import ArrayDigest + >>> digest = ArrayDigest.of(np.zeros((2, 3))) + >>> digest.dtype, digest.shape, len(digest.sha256) + ('float64', (2, 3), 64) + """ + + sha256: str + dtype: str + shape: tuple[int, ...] + + @classmethod + def of(cls, values: np.ndarray) -> "ArrayDigest": + """Return the digest of an array's contents. + + Parameters + ---------- + values : numpy.ndarray + The array; read once, in C order and little-endian byte order, a block at + a time unless it is held that way already, so that a broadcast input never + becomes a whole volume. + + Returns + ------- + ArrayDigest + The digest, the dtype and the shape. + """ + array = np.asarray(values) + # One byte order, so that the same numbers digest alike on every platform. + little = array.dtype.newbyteorder("<") + digest = hashlib.sha256() + if array.size == 0: + pass + elif array.flags.c_contiguous and array.dtype == little: + digest.update(memoryview(array).cast("B")) + else: + # NumPy's stubs leave "contig" out of the flags they list. + for block in np.nditer( # pyright: ignore[reportCallIssue, reportUnknownVariableType] + array, + flags=["external_loop", "buffered"], + op_flags=["readonly", "contig"], # pyright: ignore[reportArgumentType] + op_dtypes=[little], + casting="equiv", + order="C", + buffersize=_DIGEST_BLOCK, + ): + digest.update(np.asarray(block).tobytes()) + return cls( + sha256=digest.hexdigest(), + dtype=str(little), + shape=tuple(int(size) for size in array.shape), + ) + + +def _array_name(digest: ArrayDigest) -> str: + """Return the name a record array is stored under. + + The layout is part of the name because the digest covers only the bytes: arrays of + equal bytes and different dtypes or shapes are different arrays, and one must not + take the other's place in the store. + + - ``digest``: the array's digest, dtype and shape. + """ + shape = "x".join(str(size) for size in digest.shape) + return f"{digest.sha256}-{digest.dtype}-{shape}" + + +@dataclass(frozen=True) +class StatedInput: + """An array input recorded by the identity its caller states, its bytes unread. + + What a run record holds under ``input_identity`` + (:meth:`solprop.driver.simulation.Simulation.with_input`): for a caller that + already identifies its input, such as by a scan's delay and its own configuration, + and has no use for a digest of it. The identity is taken on trust: unlike an + :class:`ArrayDigest`, two arrays that differ record the same thing if they are + stated alike, so a resume and a scan's cache accept one for the other. + + Attributes + ---------- + identity : str + What the caller says the input is. + dtype : str + The array's dtype, as NumPy names it (``"complex128"``), little-endian as an + :class:`ArrayDigest` records it. + shape : tuple of int + The array's shape. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.serialisation import StatedInput + >>> StatedInput.of(np.zeros((2, 3), complex), "delay 12 fs") + StatedInput(identity='delay 12 fs', dtype='complex128', shape=(2, 3)) + """ + + identity: str + dtype: str + shape: tuple[int, ...] + + @classmethod + def of(cls, values: np.ndarray, identity: str) -> "StatedInput": + """Return the record of an array input under a stated identity. + + Parameters + ---------- + values : numpy.ndarray + The array, of which only the dtype and the shape are read. + identity : str + What the caller says it is. + + Returns + ------- + StatedInput + The identity, the dtype and the shape. + """ + return cls( + identity=identity, + dtype=str(values.dtype.newbyteorder("<")), + shape=tuple(int(size) for size in values.shape), + ) + + +def recorded_input(value: object) -> object: + """Return an input as the run record carries it: inline, or by its digest. + + Parameters + ---------- + value : object + A pulse, a spatial pulse or an array input. + + Returns + ------- + object + `value` itself, unless it is an array of more than :data:`INLINE_ARRAY_LIMIT` + elements, which is recorded as its :class:`ArrayDigest`. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.serialisation import recorded_input + >>> recorded_input(np.zeros(4)).shape + (4,) + >>> type(recorded_input(np.zeros(2**20 + 1))).__name__ + 'ArrayDigest' + """ + if isinstance(value, np.ndarray) and not _recorded_inline(value): + return ArrayDigest.of(value) + return value + + +def _recorded_inline(values: np.ndarray) -> bool: + """Return whether a run record carries an array input itself, not its digest. + + - Returns: True for at most :data:`INLINE_ARRAY_LIMIT` elements. + """ + return values.size <= INLINE_ARRAY_LIMIT + + +def _canonical_json(value: JsonValue) -> str: + """Return the one textual form of a structure that a digest may be taken over.""" + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _decode_array(record: Mapping[str, object]) -> np.ndarray: + """Return the array a ``__ndarray__`` record describes. + + The nested list :meth:`numpy.ndarray.tolist` produced already carries the shape, so + only the dtype has to be restored, and the imaginary part rejoined when there is + one. + + - Returns: the array, with its original dtype and shape. + """ + dtype = np.dtype(str(record["dtype"])) + if "__imaginary__" in record: + real = np.array(record["__ndarray__"], dtype=np.float64) + # Assigning the two views rather than forming real + 1j*imaginary: complex + # arithmetic does not preserve a signed zero, and the digest is taken over the + # text of these numbers, where -0.0 and 0.0 differ. + values = np.empty(real.shape, dtype=dtype) + values.real = real + values.imag = np.array(record["__imaginary__"], dtype=np.float64) + return values + return np.array(record["__ndarray__"], dtype=dtype) + + +def _decode(value: object) -> object: + """Return a serialised value with an array field rebuilt. + + An array is the only form a record type holds that JSON cannot carry directly, so + it is the only one rebuilt; a nested configuration dictionary is left as plain + JSON, because the physics objects it describes are not reconstructible in general + (a callable was recorded by ``repr``). + + - Returns: the rebuilt value, or ``value`` itself when there is nothing to rebuild. + """ + if isinstance(value, dict) and "__ndarray__" in value: + return _decode_array(value) + return value + + +def _resolved( + value: JsonValue, + arrays: Mapping[str, object], + found: dict[str, np.ndarray], +) -> object: + """Return a stored document with each array reference replaced by its array.""" + if isinstance(value, list): + return [_resolved(item, arrays, found) for item in value] + if not isinstance(value, dict): + return value + if _RECORD_ARRAY not in value: + return {key: _resolved(item, arrays, found) for key, item in value.items()} + name = str(value[_RECORD_ARRAY]) + if name not in found: + if name not in arrays: + msg = ( + f"stored metadata names the array {name}, which is not beside it: " + "read a record through the file that holds it" + ) + raise OutputError(msg) + array = np.asarray(arrays[name]) + if _array_name(ArrayDigest.of(array)) != name: + msg = f"the array stored as {name} does not match that name" + raise OutputError(msg) + found[name] = array + return found[name] + + +def _dotted(path: Sequence[str | int]) -> str: + """Return a path into a stored record as a refusal writes it.""" + text = "" + for part in path: + text += f"[{part}]" if isinstance(part, int) else f".{part}" if text else part + return text + + +def _stored_entry( + document: Mapping[str, object], + name: str, + path: tuple[str, ...], + *, + default: object = None, + required: bool = False, +) -> object: + """Return one entry of a stored record, reading an absent one as `default`.""" + if name in document: + return document[name] + if required: + msg = f"stored metadata lacks {_dotted((*path, name))}" + raise OutputError(msg) + return default + + +def _rebuild[T]( + build: type[T], + record: Mapping[str, object], + path: tuple[str | int, ...] = (), +) -> T: + """Return a record instance from the dictionary :func:`serialise` made of it. + + The instance is for reading, so a file another build of the same output schema wrote + stays readable: a field the class no longer has is dropped, and an absent field + takes the field's default. A resume never compares the rebuilt instance: it compares + the stored text (:meth:`~solprop.output.metadata.RunMetadata.configuration_hash`). + """ + fields = {entry.name: entry for entry in dataclasses.fields(build) if entry.init} # pyright: ignore[reportArgumentType] - a record dataclass + arguments = {name: _decode(item) for name, item in record.items() if name in fields} + for name, entry in fields.items(): + if ( + name not in arguments + and entry.default is dataclasses.MISSING + and entry.default_factory is dataclasses.MISSING + ): + msg = ( + f"stored metadata lacks {_dotted((*path, name))}, which this build " + "requires" + ) + raise OutputError(msg) + return build(**arguments) + + +def _optional_text(value: JsonValue) -> str | None: + """Return a stored optional string, ``None`` when null. + + - Returns: the string, or ``None``. + """ + return None if value is None else str(value) diff --git a/src/solprop/output/snapshots.py b/src/solprop/output/snapshots.py index 25e5c950..31bd51e4 100644 --- a/src/solprop/output/snapshots.py +++ b/src/solprop/output/snapshots.py @@ -30,7 +30,7 @@ from solprop.exceptions import OutputError, TDSEArchiveError from solprop.output._writer_lock import WriterLock from solprop.output.hdf5 import HDF5Output, ResumeState, resume -from solprop.output.schema import RunMetadata +from solprop.output.metadata import RunMetadata from solprop.output.tdse_archive import ( ArchiveOutputs, TDSEArchive, diff --git a/src/solprop/output/store.py b/src/solprop/output/store.py new file mode 100644 index 00000000..1f857024 --- /dev/null +++ b/src/solprop/output/store.py @@ -0,0 +1,1579 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The one save protocol both output handlers implement, and the field retention. + +:class:`Output` is the protocol :class:`solprop.output.memory.MemoryOutput` and +:class:`solprop.output.hdf5.HDF5Output` both implement, so processing reads either +through the same accessors; :class:`PlaneStore` is the base class both are built on, +and :class:`FieldRetention` says which planes keep their field. A handler converts each +plane's field to the optics convention on the way to storage +(``docs/dev/conventions.md`` §2; this module and the handlers are boundary place 2). + +The manual page is ``docs/guide/results/output.md``. +""" + +import dataclasses +import json +from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import ClassVar, Literal, Protocol, cast, runtime_checkable + +import numpy as np +from jaxtyping import Complex, Int + +from solprop import fourier +from solprop._display import Displayed, engineering, line +from solprop._process import parallel_rows +from solprop.exceptions import OutputError +from solprop.modes.material import JsonValue +from solprop.output.metadata import RunMetadata, resolve_metadata_write +from solprop.output.records import ( + Convention, + RecipeRecord, + RunOutcome, + SavedPlane, + StoredField, + TerminalCheckpoint, + _amended_outcome, + _holds_carried, + _json_copy, + _prepend_checkpoint_history, + _with_phase, +) + +__all__ = [ + "CheckpointOutput", + "FieldRetention", + "FieldsChoice", + "Output", + "PlaneLayout", + "PlaneStore", + "RetainingOutput", + "field_retention_refusal", + "stored_spectrum", +] + +FieldsChoice = Literal["none", "planes", "all"] +"""Which saved planes keep their field: none of them, the named ones, or all.""" + + +def field_retention_refusal(fields: object, field_planes: object) -> str | None: + """Say what is wrong with a field-retention request, or that nothing is. + + The one validator behind :class:`solprop.driver.simulation.SimulationSettings`, + :func:`solprop.driver.run.run_problem`, :func:`solprop.driver.scan.run_scan` and + :class:`FieldRetention`; each door raises its own layer's error with the message. + + Parameters + ---------- + fields : object + The request, one of ``"none"``, ``"planes"`` and ``"all"``. + field_planes : object + Saved-plane indices, the entrance as 0 and negative from the end; required + exactly when `fields` is ``"planes"``. + + Returns + ------- + str or None + Why the request is refused, or ``None`` when it is valid. + + Examples + -------- + >>> from solprop.output.store import field_retention_refusal + >>> field_retention_refusal("planes", [0, -1]) is None + True + >>> field_retention_refusal("planes", []) + "field_planes is required exactly when fields='planes'" + """ + if fields not in ("none", "planes", "all"): + return f"fields must be none, planes or all, got {fields!r}" + try: + planes = tuple(cast("Sequence[object]", field_planes)) + except TypeError: + return ( + "field_planes must be a sequence of saved-plane indices, got " + f"{field_planes!r}" + ) + if any( + isinstance(index, bool) or not isinstance(index, int | np.integer) + for index in planes + ): + return f"field_planes must hold integer saved-plane indices, got {planes!r}" + if (fields == "planes") != bool(planes): + return "field_planes is required exactly when fields='planes'" + return None + + +@dataclass(frozen=True) +class FieldRetention: + """Which saved planes of a run keep their field (SEP 22 section 5.1). + + A run is **homogeneous**: it keeps the field on every plane, on the planes it + names, or on none, and its statistics on every plane either way. A plane without + its field stores no ``field`` and no ``noise_phase``; what it saves is its + position, its statistics and its step history. + + Attributes + ---------- + fields : {'none', 'planes', 'all'} + Keep no field, the fields of `field_planes`, or every field (the default). + field_planes : tuple of int + Saved-plane indices for ``"planes"``, the entrance as 0 and negative from the + end until :meth:`resolved`; empty otherwise. + + Raises + ------ + OutputError + If the request is not valid (:func:`field_retention_refusal`). + + Examples + -------- + >>> from solprop.output.store import FieldRetention + >>> kept = FieldRetention("planes", (0, -1)).resolved(5) + >>> kept.field_planes, kept.keeps(4), kept.keeps(2), kept.kept(5) + ((0, 4), True, False, 2) + """ + + fields: FieldsChoice = "all" + field_planes: tuple[int, ...] = () + + def __post_init__(self) -> None: + """Validate the request and adopt plain integer indices. + + Raises + ------ + OutputError + Naming what is wrong. + """ + refusal = field_retention_refusal(self.fields, self.field_planes) + if refusal is not None: + raise OutputError(refusal) + object.__setattr__( + self, "field_planes", tuple(int(index) for index in self.field_planes) + ) + + def resolved(self, n_planes: int) -> "FieldRetention": + """Return the rule with its indices counted from the entrance, sorted, unique. + + Parameters + ---------- + n_planes : int + The planes the run saves, the entrance included. + + Returns + ------- + FieldRetention + The same rule with non-negative indices. + + Raises + ------ + OutputError + If an index lies outside the run's planes. + """ + resolved = set() + for index in self.field_planes: + plane = index + n_planes if index < 0 else index + if not 0 <= plane < n_planes: + msg = f"field plane {index} is outside a run of {n_planes} saved planes" + raise OutputError(msg) + resolved.add(plane) + return FieldRetention(self.fields, tuple(sorted(resolved))) + + def keeps(self, index: int) -> bool: + """Return whether the saved plane at `index` keeps its field. + + Parameters + ---------- + index : int + The plane's index, the entrance as 0; the rule must be :meth:`resolved`. + + Returns + ------- + bool + True for every plane of an ``"all"`` run, a named plane of a ``"planes"`` + run, and no plane of a ``"none"`` run. + """ + if self.fields == "all": + return True + return index in self.field_planes + + def kept(self, n_planes: int) -> int: + """Return how many of a run's planes keep their field. + + Parameters + ---------- + n_planes : int + The planes the run saves; the rule must be :meth:`resolved`. + + Returns + ------- + int + The planes whose field is stored. + """ + if self.fields == "all": + return n_planes + return len(self.field_planes) + + def describe(self) -> str: + """Return the rule in words, for a refusal naming it. + + Returns + ------- + str + For example ``"fields='planes' (saved planes 0, 4)"``. + """ + if self.fields == "planes": + planes = ", ".join(str(index) for index in self.field_planes) + return f"fields='planes' (saved planes {planes})" + return f"fields={self.fields!r}" + + +@dataclass(frozen=True) +class PlaneLayout: + """What every plane of one run has in common, fixed by the first plane saved. + + Attributes + ---------- + field_shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the stored field, or of the run's state when + it keeps no field; empty when neither is known. + stat_names : tuple of str + The statistic names, sorted. + has_noise_phase : bool + Whether a noise phase is stored beside each stored field. + step_stat_names : tuple of str + The statistics the between-planes channel carries, sorted; empty for a run + without a periodic subset, which is the default. + retention : FieldRetention + Which planes keep their field, resolved; every plane by default. + + Examples + -------- + >>> from solprop.output.store import PlaneLayout + >>> layout = PlaneLayout( + ... field_shape=(1, 1, 8), stat_names=("energy",), has_noise_phase=False + ... ) + >>> layout.field_shape, layout.stat_names, layout.retention.fields + ((1, 1, 8), ('energy',), 'all') + """ + + field_shape: tuple[int, ...] + stat_names: tuple[str, ...] + has_noise_phase: bool + step_stat_names: tuple[str, ...] = () + retention: FieldRetention = FieldRetention() + + +@runtime_checkable +class Output(Protocol): + """What every output handler does, and all that processing may assume of one. + + One protocol so that :class:`solprop.output.memory.MemoryOutput` and + :class:`solprop.output.hdf5.HDF5Output` are interchangeable: a run is + written to memory for a scan and to disk for a long propagation without the driver + knowing which. + + Everything a handler hands back through ``__getitem__`` is *as stored*, so the field + is in the optics convention; ``fields`` is the accessor that also offers the + internal one, for direct comparison with a file stored in the FFT convention. + """ + + def open(self, metadata: RunMetadata, *, force: bool = False) -> None: + """Begin a run, recording what produced it. + + Parameters + ---------- + metadata : RunMetadata + The record of the run about to start. + force : bool, optional + Whether to overwrite metadata already held for a different run. + """ + ... + + def save(self, plane: SavedPlane) -> None: + """Store one saved plane. + + A run gives the handler the backend's observation itself, not a copy: the + plane's arrays are read-only, nothing writes to them again, and a handler may + keep them or reduce them and let them go, with no copy of its own. + + Parameters + ---------- + plane : SavedPlane + The position, field, statistics and step history to store. On a plane that + keeps no field, ``plane.field`` and ``plane.noise_phase`` are ``None`` + (:class:`RetainingOutput`). + """ + ... + + def close(self) -> None: + """Finish the run, releasing any resource held. Calling it twice is harmless.""" + ... + + @property + def n_saved(self) -> int: # numpydoc ignore=RT01 + """How many planes have been stored.""" + ... + + @property + def metadata(self) -> RunMetadata | None: # numpydoc ignore=RT01 + """Record of the run, or ``None`` before it is opened.""" + ... + + @property + def stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The names of the statistics stored at every plane. + + The names, sorted; empty before the first plane is saved. + """ + ... + + @property + def step_stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The statistics stored between planes, if any. + + The names, sorted; empty when the run recorded no periodic subset. + """ + ... + + def __getitem__(self, key: str) -> np.ndarray: + """Return one stored array by name. + + Parameters + ---------- + key : str + ``'field'``, ``'z'``, ``'noise_phase'``, ``'field_planes'``, + ``'stats/'``, ``'step_history/'`` or ``'step_stats/'``. + + Returns + ------- + numpy.ndarray + The stored array, in the convention it is stored in. + """ + ... + + def __contains__(self, key: object) -> bool: + """Return whether ``self[key]`` would return an array, never raising. + + Parameters + ---------- + key : object + A candidate key. + + Returns + ------- + bool + ``True`` exactly when ``__getitem__`` would return an array for it. + """ + ... + + def fields(self, convention: Convention = "optics") -> np.ndarray: + """Return every stored field, in the requested sign convention. + + Parameters + ---------- + convention : {'optics', 'fft'}, optional + ``'optics'`` returns the arrays as stored; ``'fft'`` converts them back to + the internal convention a backend works in, for direct comparison with a + file stored in the FFT convention. + + Returns + ------- + numpy.ndarray + Shape ``(n_z, n_modes, n_pol, n_omega)``, complex. + """ + ... + + +@runtime_checkable +class CheckpointOutput(Protocol): + """Declare optional outcome and terminal-checkpoint persistence for a handler.""" + + def record_outcome( + self, outcome: RunOutcome, checkpoint: TerminalCheckpoint | None = None + ) -> None: + """Persist execution status and an optional accepted terminal field. + + Parameters + ---------- + outcome : RunOutcome + Cumulative work and completion status. + checkpoint : TerminalCheckpoint or None + Accepted field between regular saves. None retains a pending checkpoint + during progress and clears it when the run completes. + """ + ... + + @property + def outcome(self) -> RunOutcome | None: # numpydoc ignore=RT01 + """Recorded status, or None when no outcome was recorded.""" + ... + + @property + def terminal_checkpoint(self) -> TerminalCheckpoint | None: # numpydoc ignore=RT01 + """Latest accepted terminal field outside regular observations.""" + ... + + +@runtime_checkable +class RetainingOutput(Protocol): + """Declare that a handler accepts field-free planes, and is told the run's rule. + + Separate from :class:`Output`, as :class:`CheckpointOutput` is, because it is + optional: a handler that stores every field need not implement it, and a run that + keeps fewer than every field refuses a handler that does not + (``fields="planes"`` and ``fields="none"``, + :class:`~solprop.driver.simulation.SimulationSettings`). On a plane that keeps no + field, :meth:`Output.save` is given ``field=None`` and ``noise_phase=None``. + + Examples + -------- + >>> from solprop.output.store import RetainingOutput + >>> class Spectra: + ... def retain(self, retention, field_shape): + ... self.keeps = retention.field_planes + >>> isinstance(Spectra(), RetainingOutput) + True + """ + + def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: + """Say which planes of the run keep their field, before the first is saved. + + The run loop (:func:`solprop.driver.run.run_problem`) calls it once per run, + after :meth:`Output.open` and the handler's ``reserve`` if it has one and + before any :meth:`Output.save`, and for ``fields="all"`` too. + + Parameters + ---------- + retention : FieldRetention + The rule, already :meth:`~FieldRetention.resolved` against the run's planes + (its indices count from the entrance, and none is negative). + field_shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the run's state, known here even when no + plane will carry a field. + + Raises + ------ + OutputError + A handler that already holds planes under a different rule raises this, + rather than mixing two rules in one record. + """ + ... + + +def stored_spectrum( + field: Complex[np.ndarray, "*shape n_omega"], + kind: fourier.GridKind, + out: Complex[np.ndarray, "*shape n_omega"] | None = None, +) -> Complex[np.ndarray, "*shape n_omega"]: + """Return :func:`solprop.fourier.spectrum_to_optics` of a large field, in threads. + + One thread's conjugation and sign flip cost more than the state's whole crossing + from a device, and a state's rows are independent. The bits are the same. + + Parameters + ---------- + field : numpy.ndarray + A contiguous spectrum in the internal convention. + kind : {'field', 'envelope'} + The grid's spectral layout. + out : numpy.ndarray or None, optional + Contiguous room to write it to; a new array by default. + + Returns + ------- + numpy.ndarray + `out`, holding the spectrum in the optics convention. + """ + target = np.empty_like(field) if out is None else out + rows = field.reshape(-1, field.shape[-1]) + written = target.reshape(-1, field.shape[-1]) + + def _convert(selected: slice) -> None: + """Convert the selected rows into their room.""" + fourier.spectrum_to_optics(rows[selected], kind=kind, out=written[selected]) + + parallel_rows(rows.shape[0], field.nbytes, _convert) + return target + + +_EXPORT_HOOKS = ("save", "_append", "_stored_field") +"""The plane store methods that see a plane's field, whose owners must vouch for the +exported path, where a kept plane reaches them without one +(``PlaneStore.takes_exports``).""" + + +def _takes_exports_by_mro(cls: type) -> bool: + """Return whether a plane store class takes exported fields, by its MRO. + + The nearest class that declares ``_takes_exports`` in its own body and says + ``False`` opts the class out. Otherwise each of :data:`_EXPORT_HOOKS` is resolved + as a call would resolve it, and the class that defines it must be the base store + or declare ``_takes_exports = True`` in its own body: a ``save`` or a field hook + from a subclass or a mixin that does not vouch for the exported path keeps the + class on ``save``, which is given every plane with its field. + """ + mro = cls.__mro__ + declared = next((klass for klass in mro if "_takes_exports" in vars(klass)), None) + if declared is not None and vars(declared)["_takes_exports"] is not True: + return False + for name in _EXPORT_HOOKS: + owner = next((klass for klass in mro if name in vars(klass)), None) + if owner is None or owner is PlaneStore: + continue + if vars(owner).get("_takes_exports") is not True: + return False + return True + + +class PlaneStore(Displayed, ABC): + """The shared half of an output handler: one save protocol for every backing store. + + :class:`solprop.output.memory.MemoryOutput` and + :class:`solprop.output.hdf5.HDF5Output` differ only in where the arrays go. What + they must not differ in is *what* goes there: the sign convention of the stored + field, the metadata overwrite rule, the key names and the step-history layout. Those + live here, once, which is what makes the two handlers store a run bitwise + identically, with one overwrite rule and one growth strategy. + + The base class also holds the run's **layout** -- the field's shape, the statistic + names and whether a noise phase is stored -- fixed by the first plane saved and + checked against every later one, so that a plane that does not fit is refused by + name in either handler, rather than being dropped or raising deep inside the writer. + + A subclass implements four hooks: ``_write_metadata``, ``_append``, ``_read`` and + ``_release``, and may implement a fifth, ``_write_annotation``, to persist the + post-run annotations (:meth:`annotate`) a store held only in memory otherwise. + + A driver that knows how many planes the run will save says so with + :meth:`reserve`, and each store sizes its first allocation for them; a run that + saves more grows past it, so the number is a hint, never a limit. + + A run's session exports a kept field straight into the stored form (SEP 47 + section 5.4): :meth:`field_destination` gives the room to fill, a store's own + (`MemoryOutput`) or none, for a new array (`HDF5Output`), and + :meth:`save_exported` takes the plane, so that the field is converted once and, + in memory, written once, where it is kept. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> from solprop.output.store import Output, PlaneStore + >>> handler = MemoryOutput() + >>> isinstance(handler, PlaneStore), isinstance(handler, Output) + (True, True) + """ + + _takes_exports: ClassVar[bool] = True + """Declared in a class's own body: whether its own ``save``, ``_append`` and + ``_stored_field`` keep to the exported path's contract (:attr:`takes_exports`).""" + + _exports: ClassVar[bool] = True + """Whether a run stores kept fields here through :meth:`save_exported`, resolved + once per class from its method resolution order (:func:`_takes_exports_by_mro`).""" + + def __init_subclass__(cls, **kwargs: object) -> None: + """Resolve whether the subclass takes exported fields, by its MRO. + + Parameters + ---------- + **kwargs : object + Passed on to the next class. + """ + super().__init_subclass__(**kwargs) + cls._exports = _takes_exports_by_mro(cls) + + def __init__(self) -> None: + """Start with nothing stored and nothing opened.""" + self._metadata: RunMetadata | None = None + self._recipe: RecipeRecord | None = None + self._n_saved = 0 + self._planned = 0 + self._closed = False + self._layout: PlaneLayout | None = None + # Which planes keep their field, and the state's shape for a run that keeps + # none, told by the run before its first plane (`retain`). + self._retention = FieldRetention() + self._field_shape: tuple[int, ...] = () + self._annotations: dict[str, JsonValue] = {} + self._outcome: RunOutcome | None = None + self._terminal_checkpoint: TerminalCheckpoint | None = None + # The attempts and rows of the checkpoint a resume started from, which no + # regular plane holds yet: the next plane or checkpoint is preceded by them. + self._carried: SavedPlane | None = None + self._resume_outcome_pending = False + + @property + def outcome(self) -> RunOutcome | None: # numpydoc ignore=RT01 + """Execution outcome, or None when none was recorded. + + The last recorded completion state and cumulative ledgers. + """ + return self._outcome + + @property + def terminal_checkpoint(self) -> TerminalCheckpoint | None: # numpydoc ignore=RT01 + """Terminal checkpoint without treating it as a saved plane. + + A field in the internal FFT convention and output amplitude units. + """ + return self._terminal_checkpoint + + def record_recipe(self, record: RecipeRecord) -> None: + """Record the recipe the run was built from, unless one is recorded already. + + Written once, at the first run start, and never rewritten (SEP 41, S2): a + later session of the same file, from a script or from an edited recipe, + leaves the first session's recipe in place and is recorded in its own + execution record. + + Parameters + ---------- + record : RecipeRecord + The authored and resolved texts and the data files. + """ + self._check_usable() + if self.recipe is not None: + return + self._write_recipe(record) + self._recipe = record + + def _write_recipe(self, record: RecipeRecord) -> None: + """Persist the recipe; a store that lives in memory has nothing to do.""" + + @property + def recipe(self) -> RecipeRecord | None: # numpydoc ignore=RT01 + """The recipe the run was built from, or ``None`` for a run from a script.""" + return self._recipe + + def record_outcome( + self, outcome: RunOutcome, checkpoint: TerminalCheckpoint | None = None + ) -> None: + """Persist status and optional terminal field without extending regular saves. + + Parameters + ---------- + outcome : RunOutcome + Current status and cumulative call ledgers. + checkpoint : TerminalCheckpoint or None + Accepted field, with no newly requested atomic statistics. With a + ``"running"`` outcome it is a running checkpoint (SEP 47 section 5.5): an + accepted endpoint between saved planes, written so that a snapshot or a + later failure has a state to resume from. Its attempts and periodic rows + are not consumed: the next regular plane still carries them, and saving + that plane supersedes the checkpoint. ``None`` keeps a pending checkpoint + during progress and clears it otherwise; passing back the checkpoint + held (:attr:`~PlaneStore.terminal_checkpoint`), or a copy, with a closing + outcome keeps it as the state to resume from. + + Raises + ------ + OutputError + If the handler is closed or holds no metadata, or the checkpoint + disagrees with the outcome or stored field layout. + """ + self._check_usable() + self._require_metadata() + previous = self._outcome + if previous is not None: + history = previous.previous_executions + if self._resume_outcome_pending or ( + previous.status != "running" and outcome.status == "running" + ): + closing = dataclasses.asdict(previous) + closing.pop("previous_executions") + history = (*history, closing) + outcome = _amended_outcome( + outcome, + previous_executions=tuple( + cast("dict[str, object]", _json_copy(dict(record))) + for record in history + ), + ) + if checkpoint is not None: + if checkpoint.plane.z != outcome.z: + raise OutputError("checkpoint and outcome positions must agree") + if ( + self._layout is not None + and self._layout.field_shape + and checkpoint.plane.shape != self._layout.field_shape + ): + raise OutputError("checkpoint field shape differs from saved planes") + if self._n_saved and checkpoint.plane.z < self._last_saved_z(): + raise OutputError("checkpoint precedes the last regular saved plane") + if self._carried is not None and not _holds_carried( + checkpoint.plane, self._carried + ): + # A new checkpoint is preceded by what a resume carried; the held one, + # or a copy of it, holds it already. Nothing is consumed: a running + # checkpoint is superseded, and the next plane is preceded by the + # same attempts. + checkpoint = dataclasses.replace( + checkpoint, + plane=_prepend_checkpoint_history(self._carried, checkpoint.plane), + ) + elif outcome.status == "running": + checkpoint = self._terminal_checkpoint + self._write_outcome(outcome, checkpoint) + self._outcome = outcome + self._resume_outcome_pending = False + self._terminal_checkpoint = checkpoint + if outcome.status != "running": + self._carried = None + + def _last_saved_z(self) -> float: + """Return the position of the last regular saved plane. + + - Returns: the position, m. + """ + return float(self["z"][-1]) + + def _write_outcome( + self, outcome: RunOutcome | None, checkpoint: TerminalCheckpoint | None + ) -> None: + """Persist records; the memory store needs no additional storage hook. + + - ``checkpoint``: accepted checkpoint, or None to clear an older one. + """ + + @property + def annotations(self) -> Mapping[str, JsonValue]: # numpydoc ignore=RT01 + """Post-run annotations the store holds, by name. + + An annotation is something learned only once the run has closed -- the material + manifest's peak-field report + (:data:`~solprop.output.metadata.PEAK_FIELDS_ANNOTATION`) is one -- so it is not + part of the run's record: the configuration hash, a resume and a scan's reuse + never see it. A store without any, a file written before annotations existed + included, answers with an empty mapping. + + Each annotation's plain JSON values, by name; a copy. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> dict(MemoryOutput().annotations) + {} + """ + return cast("dict[str, JsonValue]", json.loads(json.dumps(self._annotations))) + + def annotate(self, name: str, value: object) -> None: + """Record a post-run annotation, replacing any of the same name. + + The run's record is not touched: an annotation stays outside the + configuration hash, so a file annotated after its run is still the same run + to :func:`solprop.output.hdf5.check_cache` and to a scan. A store may be + annotated after it is closed; a file is then reopened briefly to write it. + + Parameters + ---------- + name : str + A non-empty name without ``/``, such as + :data:`~solprop.output.metadata.PEAK_FIELDS_ANNOTATION`. + value : object + Plain JSON values: mappings with string keys, lists, strings, numbers, + booleans, None; anything else is refused here, the store's boundary. + + Raises + ------ + OutputError + If the store was never opened, the name is empty or holds ``/``, or the + value is not plain JSON. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> from solprop.output.metadata import _example_metadata + >>> handler = MemoryOutput() + >>> handler.open(_example_metadata()) + >>> handler.annotate("note", {"peak": 1.5}) + >>> handler.annotations["note"] + {'peak': 1.5} + """ + self._require_metadata() + if not name or "/" in name: + msg = ( + f"an annotation is named by a non-empty name without '/', got {name!r}" + ) + raise OutputError(msg) + try: + text = json.dumps(value, sort_keys=True, allow_nan=False) + except (TypeError, ValueError) as error: + msg = f"annotation {name!r} is not plain JSON: {error}" + raise OutputError(msg) from error + self._write_annotation(name, text) + self._annotations[name] = cast("JsonValue", json.loads(text)) + + def _write_annotation(self, name: str, text: str) -> None: + """Persist one annotation; a store that lives in memory has nothing to do.""" + + @property + def metadata(self) -> RunMetadata | None: # numpydoc ignore=RT01 + """Record of the run, or ``None`` before it is opened.""" + return self._metadata + + @property + def n_saved(self) -> int: # numpydoc ignore=RT01 + """How many planes have been stored.""" + return self._n_saved + + @property + def closed(self) -> bool: # numpydoc ignore=RT01 + """Whether the handler has been closed.""" + return self._closed + + @property + def stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The names of the statistics stored at every plane. + + Read off the layout the first plane fixed, so that the two handlers answer + alike, and a file continued or opened for reading answers from what it holds. + + The names, sorted; empty before the first plane is saved. + """ + return () if self._layout is None else self._layout.stat_names + + @property + def step_stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The statistics the between-planes channel carries. + + Empty for a run that asked for no periodic subset, which is how a reader + tells whether the channel is there to prefer over the planes. + + The names, sorted; empty when the run has no channel. + """ + return () if self._layout is None else self._layout.step_stat_names + + def _state_text(self) -> str: + """Return whether the store is open, for printing. + + - Returns: ``"open"`` or ``"closed"``. + """ + return "closed" if self._closed else "open" + + def _span_text(self) -> str: + """Return the positions the saved planes span, for printing.""" + if not self._n_saved: + return "no plane saved yet" + try: + z = np.asarray(self["z"], dtype=float) + except OutputError: + return "positions unavailable while the store is closed" + return f"{engineering(float(z[0]), 'm')} to {engineering(float(z[-1]), 'm')}" + + def __repr__(self) -> str: + """Return a one-line description: how much is stored, and whether it is open. + + Returns + ------- + str + For example ``MemoryOutput(201 planes, 2 modes, closed)``. + """ + fields: list[tuple[str, object]] = [("", f"{self._n_saved} planes")] + if self._layout is not None: + fields.append(("", "x".join(str(n) for n in self._layout.field_shape))) + fields.append(("", self._state_text())) + return line(type(self).__name__, fields) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + rows: list[tuple[str, object]] = [ + ("state", self._state_text()), + ("planes", f"{self._n_saved}, {self._span_text()}"), + ] + if self._layout is not None: + shape = " x ".join(str(n) for n in self._layout.field_shape) + rows.append(("field per plane", f"{shape} (modes x components x omega)")) + rows.append(("statistics", ", ".join(self._layout.stat_names) or "none")) + between = ", ".join(self._layout.step_stat_names) or "none" + rows.append(("between planes", between)) + if self._metadata is not None: + rows.append(("run", repr(self._metadata))) + return type(self).__name__, rows + + def __enter__(self) -> "PlaneStore": + """Return the handler, so that a ``with`` block closes it on the way out. + + Returns + ------- + PlaneStore + This handler. + """ + return self + + def __exit__(self, *details: object) -> None: + """Close the handler when the block ends, whatever happened inside it. + + Parameters + ---------- + *details : object + The exception being raised, if any; it is not suppressed. + """ + self.close() + + def open(self, metadata: RunMetadata, *, force: bool = False) -> None: + """Begin a run, recording what produced it. + + Parameters + ---------- + metadata : RunMetadata + The record of the run about to start. + force : bool, optional + Whether to overwrite metadata already held for a different run. Without it a + second, different record raises; with it the record is replaced and the + replacement logged + (:func:`~solprop.output.metadata.resolve_metadata_write`). + + Raises + ------ + OutputError + If the handler is closed, or a different record is already held and + ``force`` is false. + """ + self._check_usable() + if resolve_metadata_write(self._metadata, metadata, force=force): + self._write_metadata(metadata) + self._metadata = metadata + + def save(self, plane: SavedPlane) -> None: + """Store one saved plane, converting its field to the optics convention. + + Parameters + ---------- + plane : SavedPlane + The position, field, statistics and step history to store. Its field is in + the internal FFT convention and is written out as + :func:`solprop.fourier.spectrum_to_optics` of it; a field-free plane + stores none. + + Raises + ------ + OutputError + If the handler is closed or was never opened, or the plane does not fit + the run's layout: a field where the run keeps none, none where it keeps + one (:meth:`retain`), another shape or other statistics. + """ + self._store(plane, None) + + @property + def takes_exports(self) -> bool: # numpydoc ignore=RT01 + """Whether a run hands this store its kept fields already stored. + + A store that takes them is given each kept plane through + :meth:`save_exported`, its field exported by the run's session straight into + the stored form, and into :meth:`field_destination` (SEP 47 section 5.4). A + subclass whose ``save``, ``_append`` or ``_stored_field`` comes from a class + that does not vouch for the exported path -- its own override, or a mixin's + ahead of the store in its method resolution order -- does not: it sees every + plane, and its field, through ``save`` as before. A class vouches with + ``_takes_exports = True`` in its own body, and opts out with ``False``. + + Whether :meth:`save_exported` is how the run stores kept fields here. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> MemoryOutput().takes_exports + True + """ + return self._exports + + def field_destination( + self, plane: SavedPlane, shape: tuple[int, ...] + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"] | None: + """Return where the next plane's field is to be exported, or ``None``. + + The run's service asks before it exports a kept field (SEP 47 section 5.4). A + store with room of its own for the field returns that room, a writeable, + C-contiguous complex128 array of `shape`, and the export fills it once in the + stored form, the true-power amplitude (or a spatial route's field measure) in + the optics convention; :meth:`save_exported` then takes the plane. The room is + the store's: no other plane is given it and nothing writes to it again once the + plane is stored. A store without such room returns ``None`` and is handed a new + array, which it takes as its own. + + Parameters + ---------- + plane : SavedPlane + The plane about to be stored, without its field: its statistics fix the + layout of a store's first allocation. + shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the field. + + Returns + ------- + numpy.ndarray or None + The room, or ``None`` for a new array. This base class has none. + + Raises + ------ + OutputError + If the handler is closed or was never opened, the run keeps no field at + this plane (:meth:`retain`), or `shape` is not the run's field shape. + """ + self._check_destination(plane, shape) + return None + + def save_exported(self, plane: SavedPlane, stored: StoredField) -> None: + """Store one saved plane whose field was exported already in its stored form. + + The run's service calls it instead of :meth:`save` when the store + :attr:`takes_exports`; what is stored is what :meth:`save` stores for the same + field, since the export's conversion is the one :meth:`save` makes. + + Parameters + ---------- + plane : SavedPlane + The position, statistics and step history to store, with no field of its + own. + stored : StoredField + Its field in the stored form, in the room :meth:`field_destination` + returned or in any other array, which is copied in where the store keeps + fields in its own room; and its noise phase. + + Raises + ------ + OutputError + If `plane` carries a field, or for any reason :meth:`save` refuses a + plane. + """ + if plane.field is not None: + msg = ( + "a plane whose field was exported is stored without a field of its " + "own; save() takes a plane with its field" + ) + raise OutputError(msg) + self._store(plane, stored) + + def _check_destination(self, plane: SavedPlane, shape: tuple[int, ...]) -> None: + """Refuse to give room for a plane that cannot be stored with a field. + + - ``shape``: the field's shape. + """ + self._check_usable() + self._require_metadata() + if plane.field is not None: + msg = "a plane exported into a store's room carries no field of its own" + raise OutputError(msg) + index = self._n_saved + if not self._retention.keeps(index): + msg = ( + f"plane {index} keeps no field: the run was saved with " + f"{self._retention.describe()}" + ) + raise OutputError(msg) + held = ( + self._layout.field_shape + if self._layout is not None and self._fields_stored() + else self._field_shape + ) + if held and tuple(shape) != tuple(held): + msg = ( + f"plane {index} has field shape {tuple(shape)}, but the run stores " + f"{tuple(held)}" + ) + raise OutputError(msg) + + def _store(self, plane: SavedPlane, exported: StoredField | None) -> None: + """Store one plane, its field taken from the plane or already exported. + + - ``exported``: its field in the stored form (:meth:`save_exported`), or + ``None`` to convert the plane's own field. + """ + self._check_usable() + metadata = self._require_metadata() + if self._carried is not None: + plane = _prepend_checkpoint_history(self._carried, plane) + if exported is not None and exported.noise_phase is not None: + plane = _with_phase(plane, exported.noise_phase) + self._check_layout(plane, plane.field if exported is None else exported.values) + if exported is not None: + stored = self._take_exported(plane, exported.values) + else: + stored = ( + None + if plane.field is None + else self._stored_field(plane, metadata.grid.kind) + ) + self._append(plane, stored) + self._n_saved += 1 + self._carried = None + if self._terminal_checkpoint is not None: + # A terminal or running checkpoint is superseded by the plane beyond it + # (SEP 47 section 5.5): a resume never goes back to it. + self._write_outcome(self._outcome, None) + self._terminal_checkpoint = None + + def _take_exported( + self, + plane: SavedPlane, + exported: Complex[np.ndarray, "n_modes n_pol n_omega"], + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return an exported field as the store holds it; this base class takes it. + + A store that keeps fields in a room of its own copies a field exported + elsewhere into it (`MemoryOutput`). + """ + del plane + return exported + + def reserve(self, n_planes: int) -> None: + """Expect a run of `n_planes` saved planes in all, before the first is saved. + + A store sizes its plane-indexed arrays for this many planes when the first + plane fixes their shapes, rather than growing them plane by plane; a run that + saves more grows them past it (by doubling), and one that saves fewer uses + only what it saved, so the count is a hint and changes nothing that is + stored. The run loop (:func:`solprop.driver.run.run_problem`) reserves the + entrance plane and every save plane. A later call can only raise the count. + + Parameters + ---------- + n_planes : int + The planes the run will save, the entrance included; non-negative. + + Raises + ------ + OutputError + If `n_planes` is negative. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> handler = MemoryOutput() + >>> handler.reserve(201) + >>> handler.n_saved + 0 + """ + if n_planes < 0: + msg = f"a store reserves a non-negative number of planes, got {n_planes}" + raise OutputError(msg) + self._planned = max(self._planned, int(n_planes)) + + def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: + """Say which planes of the run keep their field, before the first is saved. + + The run loop (:func:`solprop.driver.run.run_problem`) calls it with the run's + resolved rule. A store then refuses a plane that breaks the rule, sizes its + field storage for the planes that keep one, and answers field reads on the + others by naming the rule. A store continuing a run (a resumed file) already + holds a rule and accepts only the same one. + + Parameters + ---------- + retention : FieldRetention + The rule, :meth:`~FieldRetention.resolved` against the run's planes. + field_shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the run's state, which a run that keeps no + field records in its place. + + Raises + ------ + OutputError + If the store already holds planes under another rule. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> from solprop.output.store import FieldRetention + >>> handler = MemoryOutput() + >>> handler.retain(FieldRetention("none"), (1, 1, 8)) + >>> handler.retention.fields, handler.field_shape + ('none', (1, 1, 8)) + """ + if self._n_saved and retention != self._retention: + msg = ( + f"this output holds planes saved with {self._retention.describe()}, " + f"and a run continuing it asks for {retention.describe()}; a run keeps " + "its fields by one rule, so continue it with the same fields and " + "field_planes" + ) + raise OutputError(msg) + self._retention = retention + self._field_shape = tuple(int(size) for size in field_shape) + + @property + def retention(self) -> FieldRetention: # numpydoc ignore=RT01 + """Which planes of the run keep their field. + + The rule the run declared (:meth:`retain`), every plane by default. + """ + return self._retention + + @property + def field_shape(self) -> tuple[int, ...]: # numpydoc ignore=RT01 + """Shape of one plane's field, stored or not. + + ``(n_modes, n_pol, n_omega)``; empty before anything has said it. + """ + if self._layout is not None and self._layout.field_shape: + return self._layout.field_shape + return self._field_shape + + def close(self) -> None: + """Finish the run, releasing whatever the store holds open. + + Calling it a second time does nothing, so a driver may close in a ``finally`` + and a caller may close again without checking. + """ + if self._closed: + return + self._release() + self._closed = True + + def fields(self, convention: Convention = "optics") -> np.ndarray: + """Return every stored field, in the requested sign convention. + + Parameters + ---------- + convention : {'optics', 'fft'}, optional + ``'optics'`` returns the arrays exactly as stored; ``'fft'`` converts them + back to the internal convention a backend works in, so that a file stored in + the FFT convention can be compared with them bin by bin. + + Returns + ------- + numpy.ndarray + Shape ``(n_z, n_modes, n_pol, n_omega)``, complex. + + Raises + ------ + OutputError + If nothing has been stored yet, or the run did not keep the field on + every plane (:meth:`plane_field` reads a plane that kept one). + """ + stored = self["field"] + if convention == "fft": + kind = self._require_metadata().grid.kind + return fourier.spectrum_to_internal(stored, kind=kind) + return stored + + def __getitem__(self, key: str) -> np.ndarray: + """Return one stored array by name, as stored. + + Parameters + ---------- + key : str + ``'field'``, ``'z'``, ``'noise_phase'``, ``'stats/'``, + ``'step_history/'`` or ``'step_stats/'``. The step history is + concatenated over planes, with ``'step_history/counts'`` giving the number + of attempts belonging to each; the between-planes statistics are laid out + the same way, with ``'step_stats/z'``, ``'step_stats/step'`` and + ``'step_stats/counts'`` beside the named statistics, and read back empty + for a run that recorded none; ``'noise_phase'`` is there only when the run + carried a noise model. + + Returns + ------- + numpy.ndarray + The stored array; the field in the optics convention. + + Raises + ------ + OutputError + If the key is not one of those forms, or names something not stored; for + ``'field'`` and ``'noise_phase'``, also when the run did not keep the + field on every plane, naming its ``fields`` (:meth:`plane_field` reads a + plane that kept one). ``'field_planes'`` lists the saved planes that keep + their field, for every run. + """ + group, name = _split_key(key) + if group == "" and name == "field_planes": + return self._field_planes() + if group == "" and name in ("field", "noise_phase"): + self._refuse_partial_fields(name) + return self._read(group, name) + + def _field_planes(self) -> Int[np.ndarray, " n_kept"]: + """Return the indices of the saved planes that keep their field.""" + indices = range(self._n_saved) + return np.array( + [index for index in indices if self._retention.keeps(index)], dtype=np.int64 + ) + + def _refuse_partial_fields(self, name: str) -> None: + """Refuse a whole-run field read on a run that kept fields on some planes only. + + - ``name``: ``'field'`` or ``'noise_phase'``. + """ + if self._retention.fields == "all": + return + where = ( + "no plane" + if self._retention.fields == "none" + else "only the saved planes listed by output['field_planes']" + ) + msg = ( + f"this run was saved with {self._retention.describe()}: its {name} is " + f"stored on {where}, so there is no {name} for every plane to read; " + "read a plane that kept its field with plane_field(index), or rerun with " + "fields='all'" + ) + raise OutputError(msg) + + def plane_field(self, index: int, convention: Convention = "optics") -> np.ndarray: + """Return one saved plane's field, in the requested sign convention. + + The accessor that works whatever the run kept: a plane that kept its field + returns it, and one that did not is refused by name. + + Parameters + ---------- + index : int + The saved plane, the entrance as 0 and negative from the end. + convention : {'optics', 'fft'}, optional + As for :meth:`fields`. + + Returns + ------- + numpy.ndarray + Shape ``(n_modes, n_pol, n_omega)``, complex. + + Raises + ------ + OutputError + If the index is out of range, or that plane kept no field, naming the + run's ``fields``. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output import MemoryOutput + >>> from solprop.output.metadata import _example_metadata + >>> from solprop.output.records import SavedPlane + >>> handler = MemoryOutput() + >>> handler.open(_example_metadata()) + >>> n_omega = handler.metadata.grid.n_omega + >>> handler.save(SavedPlane(z=0.0, field=np.ones((1, 1, n_omega), complex))) + >>> handler.plane_field(-1).shape + (1, 1, 2560) + """ + plane = index + self._n_saved if index < 0 else index + if not 0 <= plane < self._n_saved: + msg = ( + f"plane {index} is out of range for a run of {self._n_saved} saved " + "planes" + ) + raise OutputError(msg) + if not self._retention.keeps(plane): + msg = ( + f"saved plane {plane} kept no field: this run was saved with " + f"{self._retention.describe()}" + ) + raise OutputError(msg) + slot = ( + plane + if self._retention.fields == "all" + else self._retention.field_planes.index(plane) + ) + stored = self._read_field(slot) + if convention == "fft": + kind = self._require_metadata().grid.kind + return fourier.spectrum_to_internal(stored, kind=kind) + return stored + + def _read_field(self, slot: int) -> np.ndarray: + """Return one stored field by its row in the field storage. + + - ``slot``: the row: the plane's index in an ``"all"`` run, its position among + the kept planes otherwise. + - Returns: the stored field, optics convention. + """ + return self._read("", "field")[slot] + + def __contains__(self, key: object) -> bool: + """Return whether ``self[key]`` would return an array. + + Answers for exactly the keys :meth:`__getitem__` accepts, and never raises: + without it Python's fallback would iterate the handler with integer keys. + + Parameters + ---------- + key : object + A candidate key; anything but a string of one of :meth:`__getitem__`'s + forms is not held. + + Returns + ------- + bool + ``True`` when the handler holds the array; ``False`` for an unknown form, + a name not stored, nothing saved yet or a closed file. + """ + if not isinstance(key, str): + return False + # Asking the reader is the one way to agree with it exactly; the cost is one + # read of the array, which a membership test is followed by anyway. + try: + self[key] + except OutputError: + return False + return True + + def _stored_field( + self, plane: SavedPlane, kind: fourier.GridKind + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a plane's field as it is stored, in the optics convention. + + A handler with room for it already converts it there (`MemoryOutput`). + + - ``kind``: the grid's spectral layout. + """ + assert plane.field is not None + return stored_spectrum(plane.field, kind) + + def _check_layout( + self, + plane: SavedPlane, + stored: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, + ) -> None: + """Adopt the layout from the first plane, and refuse a later one that differs. + + - ``stored``: its field, in any convention, as only its shape is read; ``None`` + on a field-free plane. + """ + index = self._n_saved + retention = self._retention + if retention.keeps(index) != (stored is not None): + has = "carries no field" if stored is None else "carries a field" + msg = ( + f"plane {index} {has}, and the run was saved with " + f"{retention.describe()}; a run keeps its fields by one rule, declared " + "before its first plane (retain)" + ) + raise OutputError(msg) + layout = PlaneLayout( + field_shape=self._field_shape if stored is None else stored.shape, + stat_names=tuple(sorted(plane.stats)), + has_noise_phase=plane.noise_phase is not None, + step_stat_names=plane.step_stats.names, + retention=retention, + ) + if self._layout is None: + self._layout = layout + return + held = self._layout + # The entrance plane carries no rows, so the run's row-value names are learnt + # from the first plane that has any. + if not held.step_stat_names and layout.step_stat_names: + held = dataclasses.replace(held, step_stat_names=layout.step_stat_names) + self._layout = held + # Whether a noise phase is stored beside the field is learnt from the first + # plane that kept its field: a field-free plane carries none either way. + if stored is not None and not self._fields_stored(): + held = dataclasses.replace( + held, + field_shape=stored.shape, + has_noise_phase=layout.has_noise_phase, + ) + self._layout = held + if ( + stored is not None + and held.field_shape + and layout.field_shape != (held.field_shape) + ): + msg = ( + f"plane {index} has field shape {layout.field_shape}, but the run " + f"stores {held.field_shape}" + ) + raise OutputError(msg) + if layout.stat_names != held.stat_names: + msg = ( + f"plane {index} reports statistics {list(layout.stat_names)}, but the " + f"run stores {list(held.stat_names)}" + ) + raise OutputError(msg) + if stored is not None and layout.has_noise_phase is not held.has_noise_phase: + has = "carries" if layout.has_noise_phase else "does not carry" + msg = f"plane {index} {has} a noise phase, unlike those before it" + raise OutputError(msg) + # A plane may legitimately carry no rows -- the entrance plane never does, and + # at a period above one a short plane need not -- so the names are compared + # only where there are rows to name. + if layout.step_stat_names and layout.step_stat_names != held.step_stat_names: + msg = ( + f"plane {index} reports step statistics " + f"{list(layout.step_stat_names)}, but the run stores " + f"{list(held.step_stat_names)}" + ) + raise OutputError(msg) + + def _fields_stored(self) -> int: + """Return how many of the planes saved so far kept their field.""" + if self._retention.fields == "all": + return self._n_saved + return sum(1 for index in self._retention.field_planes if index < self._n_saved) + + def _check_usable(self) -> None: + """Refuse to write to a closed handler.""" + if self._closed: + msg = "this output handler is closed; open a new one to store another run" + raise OutputError(msg) + + def _require_metadata(self) -> RunMetadata: + """Return the run's metadata, refusing to work without it.""" + if self._metadata is None: + msg = "call open(metadata) before saving or reading a plane" + raise OutputError(msg) + return self._metadata + + @abstractmethod + def _write_metadata(self, metadata: RunMetadata) -> None: + """Commit the run's metadata to the backing store.""" + ... + + @abstractmethod + def _append( + self, + plane: SavedPlane, + stored: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, + ) -> None: + """Append one plane to the backing store. + + - ``plane``: the plane: its field in the internal convention, or ``None`` where + the field was exported straight into the stored form + (:meth:`~solprop.output.store.PlaneStore.save_exported`) and `stored` alone + holds it. A subclass whose own ``_append`` reads the plane's field does not + take exports (``PlaneStore.takes_exports``). + - ``stored``: that field converted to the optics convention, which is what to + store; ``None`` on a field-free plane, which stores no field. + """ + ... + + @abstractmethod + def _read(self, group: str, name: str) -> np.ndarray: + """Return one stored array. + + - ``group``: ``''`` for a top-level array, else ``'stats'`` or + ``'step_history'``. + """ + ... + + @abstractmethod + def _release(self) -> None: + """Release whatever the store holds open. Called once, by :meth:`close`.""" + ... + + +def _split_key(key: str) -> tuple[str, str]: + """Split an accessor key into its group and its name. + + - ``key``: ``'field'``, ``'z'``, ``'noise_phase'``, ``'field_planes'``, + ``'stats/'``, ``'step_history/'`` or ``'step_stats/'``. + - Returns: ``(group, name)``, the group empty for a top-level array. + """ + if key in ("field", "z", "noise_phase", "field_planes"): + return ("", key) + group, _, name = key.partition("/") + if group in ("stats", "step_history", "step_stats") and name: + return (group, name) + msg = ( + f"unknown key {key!r}; a handler holds 'field', 'z', 'noise_phase', " + "'field_planes', 'stats/', 'step_history/' and 'step_stats/'" + ) + raise OutputError(msg) diff --git a/src/solprop/physdata/selection.py b/src/solprop/physdata/selection.py index 48e44f45..ef688a6c 100644 --- a/src/solprop/physdata/selection.py +++ b/src/solprop/physdata/selection.py @@ -655,7 +655,7 @@ class MaterialManifest: Built once per run from its mixtures, modes and solids (:func:`solprop.driver.material_output.material_manifest`) and stored in its - :class:`~solprop.output.schema.RunMetadata`. A run with no optmat-backed object + :class:`~solprop.output.metadata.RunMetadata`. A run with no optmat-backed object and no selection has the empty manifest, ``MaterialManifest()``. Two parts are distinguished. The **identities and policies** -- the optmat diff --git a/src/solprop/plotting/__init__.py b/src/solprop/plotting/__init__.py index a464a02a..0873862a 100644 --- a/src/solprop/plotting/__init__.py +++ b/src/solprop/plotting/__init__.py @@ -7,7 +7,7 @@ The standard figures, drawn from whatever :mod:`solprop.processing` reads out of a saved run: the false-colour propagation maps, the time- and spectral-domain slices, the energy along the fibre, the statistics panels and the spectrogram. Every function takes an -:class:`~solprop.output.schema.Output`, returns figures and never calls +:class:`~solprop.output.store.Output`, returns figures and never calls :func:`~matplotlib.pyplot.show`, so a script decides for itself whether to save or to display. The manual page is ``docs/guide/results/plotting.md``. The cross-section of a guide or a free-space grid, drawn from the guide rather than from a run, is in diff --git a/src/solprop/plotting/geometry.py b/src/solprop/plotting/geometry.py index 6067a2d4..8ebf3ce3 100644 --- a/src/solprop/plotting/geometry.py +++ b/src/solprop/plotting/geometry.py @@ -684,7 +684,7 @@ def _example(route: str, representation: str) -> object: } """The ``(route, representation)`` pairs :func:`thumbnail` draws, each with what it shows. A route is a ``prop_*`` entry point's name and a representation one of -:data:`~solprop.output.schema.SimulationKind`.""" +:data:`~solprop.output.records.SimulationKind`.""" def thumbnail( @@ -714,7 +714,7 @@ def thumbnail( ``planar``. representation : str The transverse representation, as a run records it - (:data:`~solprop.output.schema.SimulationKind`). + (:data:`~solprop.output.records.SimulationKind`). size : int, optional Width and height in pixels, at least 16. Default 128. palette : Palette, optional diff --git a/src/solprop/plotting/propagation.py b/src/solprop/plotting/propagation.py index 6f9261d2..c6c8c1f2 100644 --- a/src/solprop/plotting/propagation.py +++ b/src/solprop/plotting/propagation.py @@ -5,7 +5,7 @@ """The standard figures of a propagation: the maps, the slices and the energy. Drawn with matplotlib from whatever :mod:`solprop.processing` returns. Every function -takes an :class:`~solprop.output.schema.Output`, returns figures and never calls +takes an :class:`~solprop.output.store.Output`, returns figures and never calls :func:`~matplotlib.pyplot.show`; the slice plots accept an ``ax`` to draw into, so a figure of several panels is assembled by the caller rather than by a keyword here. @@ -29,7 +29,7 @@ from matplotlib.figure import Figure from solprop.optics import TransferFunction -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.plotting.style import ( ENERGY_TO_UJ, T_TO_FS, @@ -120,7 +120,7 @@ def prop_2d( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. axis : {'f', 'wavelength', 'omega'} Spectral abscissa of the left panel; frequency, in Hz, by default, where the @@ -244,7 +244,7 @@ def time_1d( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. z : float, sequence of float or None Positions to draw, m; the end of the run by default. @@ -361,7 +361,7 @@ def spec_1d( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. z : float, sequence of float or None Positions to draw, m; the end of the run by default. @@ -469,7 +469,7 @@ def plot_energy( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. modes : int, sequence of int, 'sum' or None Which modes to draw. diff --git a/src/solprop/plotting/spectrograms.py b/src/solprop/plotting/spectrograms.py index 77e2c50c..c6bb7e62 100644 --- a/src/solprop/plotting/spectrograms.py +++ b/src/solprop/plotting/spectrograms.py @@ -18,7 +18,7 @@ from solprop.constants import C from solprop.optics import TransferFunction -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.plotting.propagation import DEFAULT_TIME_RANGE, DEFAULT_WAVELENGTH_RANGE from solprop.plotting.style import ( T_TO_FS, @@ -63,7 +63,7 @@ def spectrogram( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. z : float or None Position of the plane, m; the end of the run by default. diff --git a/src/solprop/plotting/statistics.py b/src/solprop/plotting/statistics.py index 43c4d5a0..c48f3f6a 100644 --- a/src/solprop/plotting/statistics.py +++ b/src/solprop/plotting/statistics.py @@ -20,7 +20,7 @@ from matplotlib.figure import Figure from solprop.constants import C -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.plotting.style import ( ENERGY_TO_UJ, T_TO_FS, @@ -100,7 +100,7 @@ def plot_stats(output: Output, **kwargs: Any) -> list[Figure]: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. **kwargs : Any Passed to :meth:`matplotlib.axes.Axes.plot`. diff --git a/src/solprop/plotting/style.py b/src/solprop/plotting/style.py index cd9febfb..4e68677e 100644 --- a/src/solprop/plotting/style.py +++ b/src/solprop/plotting/style.py @@ -26,7 +26,7 @@ from solprop.constants import C from solprop.exceptions import ProcessingError -from solprop.output.schema import Output, PlaneStore +from solprop.output.store import Output, PlaneStore from solprop.processing.axes import ( SPECTRAL_AXES, SpectralAxis, @@ -220,7 +220,7 @@ def per_width(output: Output) -> str: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. Returns @@ -493,7 +493,7 @@ def field_modes(output: Output) -> int: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding at least one plane. Returns @@ -524,7 +524,7 @@ def mode_labels(output: Output, n_modes: int) -> list[str]: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. n_modes : int How many modes the saved field carries. diff --git a/src/solprop/processing/__init__.py b/src/solprop/processing/__init__.py index c0a2d68d..1d44f726 100644 --- a/src/solprop/processing/__init__.py +++ b/src/solprop/processing/__init__.py @@ -4,7 +4,7 @@ """Post-processing of saved runs. -Read a run back through the :class:`solprop.output.schema.Output` protocol -- a memory +Read a run back through the :class:`solprop.output.store.Output` protocol -- a memory handler or an HDF5 file, through the same accessors -- and get fields, spectra, spectral densities, pulse metrics and the coherence of an ensemble, in the optics convention and in SI units. The manual page is ``docs/guide/results/processing.md``. diff --git a/src/solprop/processing/_examples.py b/src/solprop/processing/_examples.py index b48e38b6..524c7269 100644 --- a/src/solprop/processing/_examples.py +++ b/src/solprop/processing/_examples.py @@ -22,10 +22,10 @@ from solprop.modes.guide import ModalGuide from solprop.modes.protocol import ChannelLabel from solprop.output.memory import MemoryOutput -from solprop.output.schema import ( +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( GridRecord, ModeRecord, - RunMetadata, SavedPlane, StepperRecord, StepStats, diff --git a/src/solprop/processing/axes.py b/src/solprop/processing/axes.py index 0faee6ae..84bbf7d9 100644 --- a/src/solprop/processing/axes.py +++ b/src/solprop/processing/axes.py @@ -4,8 +4,8 @@ """Rebuild the axes and the layout of a saved run from its grid record. -A saved run carries a :class:`solprop.output.schema.GridRecord` -- scalars only, so that -the metadata stays small and its digest cheap -- and not the +A saved run carries a :class:`solprop.output.records.GridRecord` -- scalars only, so +that the metadata stays small and its digest cheap -- and not the :class:`solprop.grid.TimeGrid` it was computed on. Everything else in :mod:`solprop.processing` needs the axes back, and :mod:`solprop.stats` needs a grid object, so this module is the one place that rebuilds them. @@ -40,7 +40,8 @@ from solprop.modes.protocol import TransverseMeasure from solprop.modes.slab import PlanarGrid from solprop.output.capability import reading_refusal -from solprop.output.schema import Convention, GridRecord, Output, PlaneStore +from solprop.output.records import Convention, GridRecord +from solprop.output.store import Output, PlaneStore __all__ = [ "BANDS", @@ -85,7 +86,7 @@ def grid_from_record(record: GridRecord) -> TimeGrid: Parameters ---------- - record : solprop.output.schema.GridRecord + record : solprop.output.records.GridRecord The grid record of the run. Returns @@ -102,7 +103,7 @@ def grid_from_record(record: GridRecord) -> TimeGrid: Examples -------- >>> from solprop.grid import EnvelopeGrid - >>> from solprop.output.schema import GridRecord + >>> from solprop.output.records import GridRecord >>> from solprop.processing.axes import grid_from_record >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> rebuilt = grid_from_record(GridRecord.from_grid(grid)) @@ -151,7 +152,7 @@ def grid_of(output: Output) -> TimeGrid: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output A memory or HDF5 output handler that has been opened. Returns @@ -198,7 +199,7 @@ def spatial_grid_of(output: Output) -> RadialGrid | FreeGrid | PlanarGrid: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output A memory or HDF5 output handler that has been opened. Returns @@ -305,7 +306,7 @@ def of(cls, measure: TransverseMeasure) -> "TransverseUnits": ------- TransverseUnits The labels. The measure is already validated where it is read - (:attr:`solprop.output.schema.RunMetadata.transverse_measure`). + (:attr:`solprop.output.metadata.RunMetadata.transverse_measure`). """ if measure == "area": return cls(measure, "W", "J") @@ -317,14 +318,14 @@ def transverse_units(output: Output) -> TransverseUnits: A run on modes uniform in y -- the hollow planar guide of :class:`solprop.modes.slab.SlabMode` -- records that its transverse integrals are - per metre of y (:attr:`solprop.output.schema.RunMetadata.transverse_measure`): its + per metre of y (:attr:`solprop.output.metadata.RunMetadata.transverse_measure`): its powers, energies and the processing functions that integrate them are then in W/m and J/m, from the same formulas. A handler without metadata is taken to measure an area, as every run did before the key existed. Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. Returns @@ -485,7 +486,7 @@ def layout(output: Output) -> tuple[int, int, int, int]: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output A memory or HDF5 output handler that has been opened and holds at least one plane. diff --git a/src/solprop/processing/beams.py b/src/solprop/processing/beams.py index bd4a4e36..75d1030d 100644 --- a/src/solprop/processing/beams.py +++ b/src/solprop/processing/beams.py @@ -21,7 +21,7 @@ from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.grid import RadialGrid -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing.axes import ( SpectralAxis, grid_of, @@ -131,7 +131,7 @@ def on_axis_spectra( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. axis : {'f', 'omega', 'wavelength'}, optional The spectral abscissa. @@ -166,7 +166,7 @@ def radial_spectrum( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. axis : {'f', 'omega', 'wavelength'}, optional The spectral abscissa. @@ -216,7 +216,7 @@ def far_field( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. axis : {'f', 'omega', 'wavelength'}, optional The spectral abscissa. @@ -297,7 +297,7 @@ def fluence_along( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. Returns @@ -328,7 +328,7 @@ def radial_intensity( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. plane : int or None, optional The plane, negative from the end; the last when neither it nor ``z`` is given. @@ -364,7 +364,7 @@ def on_axis_power( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. plane : int or None, optional The plane, negative from the end; the last when neither it nor ``z`` is given. @@ -407,7 +407,7 @@ def on_axis_spectrogram( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. axis : {'f', 'omega', 'wavelength'}, optional The spectral abscissa. diff --git a/src/solprop/processing/coherence.py b/src/solprop/processing/coherence.py index 44c47d97..9f9289f7 100644 --- a/src/solprop/processing/coherence.py +++ b/src/solprop/processing/coherence.py @@ -21,7 +21,7 @@ from jaxtyping import Complex, Float from solprop.exceptions import OutputError, ProcessingError -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing.axes import Band, ModeSelection from solprop.processing.fields import get_spectrum @@ -104,7 +104,7 @@ def g12_of( Parameters ---------- - outputs : sequence of solprop.output.schema.Output + outputs : sequence of solprop.output.store.Output The ensemble, at least two opened handlers. plane : int or None Index of the plane wanted, negative from the end; with neither ``plane`` nor diff --git a/src/solprop/processing/compression.py b/src/solprop/processing/compression.py index 302c6eb3..ceb70b17 100644 --- a/src/solprop/processing/compression.py +++ b/src/solprop/processing/compression.py @@ -48,7 +48,7 @@ TransferFunction, ) from solprop.output.capability import capability_of -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing import spectra, time from solprop.processing.axes import _keep_index, _plane_field, grid_of from solprop.processing.fields import _fft_norm, _to_time @@ -463,7 +463,7 @@ def apply_transfer( ---------- transfer : TransferFunction The element, from :mod:`solprop.optics`. - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int, optional Index of the plane, negative from the end. @@ -513,7 +513,7 @@ def transform_limit( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. grid : solprop.grid.TimeGrid or None With `spectrum`, the alternative to `output`. @@ -632,7 +632,7 @@ def compress_taylor( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. grid : solprop.grid.TimeGrid or None With `spectrum`, the alternative to `output`. @@ -760,7 +760,7 @@ def compress_material( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. medium : GlassIndex, Gas, str or Mixture What the material is; see :class:`solprop.optics.Material`. @@ -876,7 +876,7 @@ def compress_mirrors( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. mirror : ChirpedMirror or str The mirror, or the name of a shipped data set @@ -994,7 +994,7 @@ def optimise_cep( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. grid : solprop.grid.TimeGrid or None With `spectrum`, the alternative to `output`. @@ -1311,7 +1311,7 @@ def compress_gratings( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None Orthogonal modal output; alternative to `grid` and `spectrum` together. pair : GratingPair Fixed grating density, angles, order, reference, pass count and working band. @@ -1391,7 +1391,7 @@ def compress_prisms( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None Orthogonal modal output; alternative to `grid` and `spectrum` together. pair : PrismPair Initial feasible pair and the settings held fixed during the search. diff --git a/src/solprop/processing/fields.py b/src/solprop/processing/fields.py index 64266e25..f971acb3 100644 --- a/src/solprop/processing/fields.py +++ b/src/solprop/processing/fields.py @@ -5,7 +5,7 @@ """Read fields, spectra and spectral densities out of a saved run. The field, spectrum and density accessors, in solprop's conventions. Each one takes an -:class:`~solprop.output.schema.Output` -- a memory handler or an HDF5 handler, through +:class:`~solprop.output.store.Output` -- a memory handler or an HDF5 handler, through the same protocol -- selects a saved plane and a set of modes, and returns an abscissa and an array whose trivial mode and polarisation axes have been squeezed away. @@ -51,7 +51,8 @@ from solprop.exceptions import ModeError, ProcessingError from solprop.grid import TimeGrid from solprop.optics import TransferFunction, stored_factor -from solprop.output.schema import Convention, Output +from solprop.output.records import Convention +from solprop.output.store import Output from solprop.processing import spectra from solprop.processing.axes import ( SPECTRAL_AXES, @@ -119,7 +120,7 @@ def nearest_plane(output: Output, z: float) -> int: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding at least one plane. z : float Position along the propagation, m. @@ -163,7 +164,7 @@ def get_spectrum( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. With ``z``, at most one of the @@ -231,7 +232,7 @@ def get_spectral_density( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. axis : {'wavelength', 'f', 'omega'} The abscissa, in m, Hz or rad/s. @@ -416,7 +417,7 @@ def get_field( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. kind : {'analytic', 'real', 'envelope'} Which representation to return. @@ -510,7 +511,7 @@ def get_power( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end; see :func:`get_spectrum`. @@ -623,7 +624,7 @@ def get_phase( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end; see :func:`get_spectrum`. @@ -860,7 +861,7 @@ def get_spectrogram( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. axis : {'f', 'omega', 'wavelength'} The spectral abscissa, in Hz, rad/s or m. diff --git a/src/solprop/processing/mixtures.py b/src/solprop/processing/mixtures.py index 1a56dfcf..b5d3aff3 100644 --- a/src/solprop/processing/mixtures.py +++ b/src/solprop/processing/mixtures.py @@ -14,7 +14,7 @@ from solprop.constants import EPS0, C from solprop.exceptions import ProcessingError from solprop.grid import TimeGrid -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing.axes import grid_of, spatial_grid_of from solprop.processing.fields import _spectral_density from solprop.processing.planes import _plane_fluence @@ -149,7 +149,7 @@ def get_power_weight(output: Output) -> Float[np.ndarray, "n_z n_modes n_pol n_o Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output Opened modal output whose record carries the power-weight factors. Returns diff --git a/src/solprop/processing/modal.py b/src/solprop/processing/modal.py index 8bdff95f..8f0a778f 100644 --- a/src/solprop/processing/modal.py +++ b/src/solprop/processing/modal.py @@ -58,7 +58,9 @@ from solprop.modes.protocol import ChannelLabel from solprop.modes.vector import ModalNodeRule from solprop.output.capability import capability_of, refusal_for, withheld_from -from solprop.output.schema import Convention, Output, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import Convention +from solprop.output.store import Output from solprop.processing.axes import _plane_field, grid_of from solprop.translate.chebyshev import _EDGE_SLACK_FRACTION from solprop.translate.general import guide_identity @@ -442,7 +444,7 @@ def channel_labels(output: Output) -> tuple[ChannelLabel, ...]: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. Returns @@ -502,7 +504,7 @@ def electric_field( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference, rebuilt from the guide the record names; its @@ -562,7 +564,7 @@ def magnetic_field( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -645,7 +647,7 @@ def reconstructed_flux( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -722,7 +724,7 @@ def instantaneous_flux( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -784,7 +786,7 @@ def coordinate_intensity( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -847,7 +849,7 @@ def plane_nodes( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -917,7 +919,7 @@ def physical_energy( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. plane : int or None, optional Index of the plane wanted, negative from the end; with neither `plane` nor diff --git a/src/solprop/processing/plane_file.py b/src/solprop/processing/plane_file.py index 3d1614b1..539ce627 100644 --- a/src/solprop/processing/plane_file.py +++ b/src/solprop/processing/plane_file.py @@ -40,7 +40,7 @@ ) from solprop.coupling.plane import AnalyticRecord from solprop.exceptions import OutputError, ProcessingError -from solprop.output.schema import GridRecord +from solprop.output.records import GridRecord from solprop.processing.axes import _space_from_record, grid_from_record from solprop.translate.flux import _admission, admission_record diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py index 0241db10..d6500492 100644 --- a/src/solprop/processing/planes.py +++ b/src/solprop/processing/planes.py @@ -46,7 +46,8 @@ from solprop.modes.guide import ModalGuide from solprop.modes.protocol import Mode from solprop.modes.slab import PlanarGrid -from solprop.output.schema import Output, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.store import Output from solprop.processing.axes import _plane_field, grid_of, spatial_grid_of from solprop.processing.modal import _checked_tables, _plane_rule, _spectra from solprop.spatial import ( @@ -186,7 +187,7 @@ def fluence_map( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened memory or HDF5 output of a spatial run. plane : int, optional Index of the saved plane, negative from the end; default the last. @@ -456,7 +457,7 @@ def field_plane( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened memory or HDF5 output whose record carries its plane reference. plane : int, optional Index of the saved plane, negative from the end; default the last. diff --git a/src/solprop/processing/scans.py b/src/solprop/processing/scans.py index d43c5a22..be944c9c 100644 --- a/src/solprop/processing/scans.py +++ b/src/solprop/processing/scans.py @@ -27,20 +27,16 @@ from solprop.output._scan_lock import read_scan from solprop.output.capability import capability_of from solprop.output.hdf5 import _dataset, _group, read_recipe +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, RecipeRecord from solprop.output.scan import ( _expand, _refuse_other_schemas, grid_values, read_point, ) -from solprop.output.schema import ( - GridRecord, - JsonValue, - Output, - RecipeRecord, - RunMetadata, - _rebuild, -) +from solprop.output.serialisation import JsonValue, _rebuild +from solprop.output.store import Output from solprop.processing.axes import band_mask, grid_from_record, spectral_axis from solprop.processing.fields import get_power, get_spectral_density from solprop.processing.time import energy diff --git a/src/solprop/processing/time.py b/src/solprop/processing/time.py index d67deaf4..61ffe118 100644 --- a/src/solprop/processing/time.py +++ b/src/solprop/processing/time.py @@ -41,7 +41,7 @@ from solprop import stats from solprop.grid import TimeGrid -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing import spectra from solprop.processing.axes import ( ModeSelection, @@ -219,7 +219,7 @@ def energy( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end; with neither ``plane`` nor @@ -297,7 +297,7 @@ def peak_power( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -376,7 +376,7 @@ def fwhm_t( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -431,7 +431,7 @@ def fwhm_f( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -481,7 +481,7 @@ def time_bandwidth( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -537,7 +537,7 @@ def arrival_time( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -597,7 +597,7 @@ def field_autocorrelation( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -654,7 +654,7 @@ def intensity_autocorrelation( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -709,7 +709,7 @@ def coherence_time( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. diff --git a/src/solprop/recipe/core.py b/src/solprop/recipe/core.py index 54cedb59..13139a3f 100644 --- a/src/solprop/recipe/core.py +++ b/src/solprop/recipe/core.py @@ -24,7 +24,7 @@ import numpy as np from solprop.exceptions import SolpropError, SolpropWarning -from solprop.output.schema import RecipeRecord +from solprop.output.records import RecipeRecord from solprop.recipe.document import ( FORMAT, NONE, @@ -62,7 +62,7 @@ from solprop.driver.simulation import Simulation from solprop.grid import TimeGrid from solprop.log import ProgressChoice, ProgressReporter, StopFlag - from solprop.output.schema import Output + from solprop.output.store import Output from solprop.recipe.pins import Pin from solprop.recipe.preview import InputPreview, ModeSetPreview from solprop.tools import DesignRoots @@ -923,7 +923,7 @@ def record( Returns ------- - solprop.output.schema.RecipeRecord + solprop.output.records.RecipeRecord The authored and resolved texts, and the data files (:mod:`solprop.recipe.provenance`). """ @@ -968,7 +968,7 @@ def run( Returns ------- - solprop.output.schema.Output + solprop.output.store.Output The run's output. """ return self.build().run(output, **run_keywords) # type: ignore[arg-type] @@ -1336,7 +1336,7 @@ def resume_file( Returns ------- - solprop.output.schema.Output + solprop.output.store.Output The handler the run continued in. Raises diff --git a/src/solprop/recipe/preview.py b/src/solprop/recipe/preview.py index e53c01ae..1c4539cd 100644 --- a/src/solprop/recipe/preview.py +++ b/src/solprop/recipe/preview.py @@ -431,7 +431,7 @@ def preview_input(recipe: "Recipe") -> InputPreview: free-space route its fluence. """ from solprop.driver.plane_view import PlaneView # noqa: PLC0415 - layer D - from solprop.output.schema import SavedPlane # noqa: PLC0415 + from solprop.output.records import SavedPlane # noqa: PLC0415 from solprop.recipe.metadata import OBJECT_ROUTES, parameters # noqa: PLC0415 quiet = recipe.resolve() diff --git a/src/solprop/recipe/provenance.py b/src/solprop/recipe/provenance.py index 0f471da2..31f1d88f 100644 --- a/src/solprop/recipe/provenance.py +++ b/src/solprop/recipe/provenance.py @@ -21,7 +21,7 @@ import numpy as np from solprop.nonlinear.raman import RamanResponse -from solprop.output.schema import RecipeFile, RecipeRecord +from solprop.output.records import RecipeFile, RecipeRecord from solprop.recipe.document import ( FORMAT, Document, @@ -227,7 +227,7 @@ def record( Returns ------- - solprop.output.schema.RecipeRecord + solprop.output.records.RecipeRecord The authored and resolved texts, and the data files. """ # A pinned recipe builds through its resolved copy, which remembers the authored diff --git a/src/solprop/stats.py b/src/solprop/stats.py index 396a3568..7ce8b00c 100644 --- a/src/solprop/stats.py +++ b/src/solprop/stats.py @@ -62,7 +62,7 @@ from solprop.modes import base as modes_base from solprop.modes.protocol import Mode from solprop.modes.slab import PlanarGrid -from solprop.output.schema import SavedPlane, SimulationKind +from solprop.output.records import SavedPlane, SimulationKind from solprop.profiles import ZProfile from solprop.spatial import ( FAR_FIELD_STATISTICS, @@ -2606,7 +2606,7 @@ def collect( Which statistics to evaluate, and the windows they need. grid : solprop.grid.TimeGrid The grid the plane's field lives on. - plane : solprop.output.schema.SavedPlane + plane : solprop.output.records.SavedPlane The plane, whose field is in the internal FFT convention. modes : collections.abc.Sequence, optional The propagated modes, in state order. Needed by ``zdw``. @@ -2652,7 +2652,7 @@ def collect( -------- >>> import numpy as np >>> from solprop.grid import EnvelopeGrid - >>> from solprop.output.schema import SavedPlane + >>> from solprop.output.records import SavedPlane >>> from solprop.pulses import GaussianPulse >>> from solprop.stats import collect, default_spec >>> grid = EnvelopeGrid.from_limits(1.0, 800e-9, (400e-9, 2e-6), 1e-12) diff --git a/src/solprop/translate/coupled.py b/src/solprop/translate/coupled.py index 9f1e0294..a5aad73f 100644 --- a/src/solprop/translate/coupled.py +++ b/src/solprop/translate/coupled.py @@ -2366,7 +2366,7 @@ def wall_identity( Returns ------- collections.abc.Mapping - The ``modal`` record of :class:`solprop.output.schema.RunMetadata`. + The ``modal`` record of :class:`solprop.output.metadata.RunMetadata`. Examples -------- @@ -2476,7 +2476,7 @@ def taper_identity( Returns ------- collections.abc.Mapping - The ``modal`` record of :class:`solprop.output.schema.RunMetadata`. + The ``modal`` record of :class:`solprop.output.metadata.RunMetadata`. Examples -------- diff --git a/src/solprop/translate/flux.py b/src/solprop/translate/flux.py index 41020a7f..f14f82ae 100644 --- a/src/solprop/translate/flux.py +++ b/src/solprop/translate/flux.py @@ -370,7 +370,7 @@ def _stored(value: object, dtype: type) -> np.ndarray: A record written to JSON or HDF5 and read back nests each array as the ``{"__ndarray__": ..., "__imaginary__": ...}`` form of - :func:`solprop.output.schema.serialise`, which a nested spatial record keeps. + :func:`solprop.output.serialisation.serialise`, which a nested spatial record keeps. """ if isinstance(value, Mapping) and "__ndarray__" in value: real = np.asarray(value["__ndarray__"], dtype=float) diff --git a/src/solprop/translate/general.py b/src/solprop/translate/general.py index f4eecdfa..6609cd6f 100644 --- a/src/solprop/translate/general.py +++ b/src/solprop/translate/general.py @@ -248,7 +248,7 @@ class GeneralModalRoute(Displayed): :func:`solprop.translate.problem.assemble` takes the leaves apart and the guide never reaches a :class:`~solprop.translate.problem.PropagationProblem` -- and the run record - persists it inside :attr:`solprop.output.schema.RunMetadata.modal`. + persists it inside :attr:`solprop.output.metadata.RunMetadata.modal`. ``None`` for a record built by hand, which :func:`modal_identity` then refuses by name rather than writing a file that cannot say what produced it. coupled_generator : numpy.ndarray or None @@ -1598,7 +1598,7 @@ def modal_identity( (:data:`solprop.stats.GENERAL_MODAL_ACCESSORS`, which :mod:`solprop.output.capability` gates with). - It goes into :attr:`solprop.output.schema.RunMetadata.modal`, which is inside + It goes into :attr:`solprop.output.metadata.RunMetadata.modal`, which is inside the resume key, so a run continued against a changed guide, table, material or channel order is refused by the field that changed. diff --git a/src/solprop/translate/run_request.py b/src/solprop/translate/run_request.py index 1f3a72bf..40bd6374 100644 --- a/src/solprop/translate/run_request.py +++ b/src/solprop/translate/run_request.py @@ -44,7 +44,7 @@ from solprop.exceptions import TranslationError from solprop.monitor import BoundaryMonitor -from solprop.output.schema import FieldRetention +from solprop.output.store import FieldRetention from solprop.stats import StatsSpec from solprop.stepper.controller import PIController from solprop.stepper.low_storage import LowStorageRungeKutta @@ -172,7 +172,7 @@ class ObservationRequest: Observe the endpoint of every ``step_planes``-th accepted step; ``0`` for none. stats : solprop.stats.StatsSpec The plane statistics, and the periodic subset with its period. - retention : solprop.output.schema.FieldRetention + retention : solprop.output.store.FieldRetention Which observed planes keep their field. """ @@ -386,7 +386,7 @@ def for_run( ---------- stats : solprop.stats.StatsSpec or None, optional The statistics; this request's own by default. - retention : solprop.output.schema.FieldRetention or None, optional + retention : solprop.output.store.FieldRetention or None, optional Field retention, already resolved against the planes; every field by default. start_plane : int, optional @@ -456,7 +456,7 @@ def describe_run( control, itinerary and planes. stats : solprop.stats.StatsSpec or None, optional The statistics; none by default. - retention : solprop.output.schema.FieldRetention or None, optional + retention : solprop.output.store.FieldRetention or None, optional Field retention, already resolved against the planes; every field by default. start_plane : int, optional The first save plane beyond the starting state. diff --git a/tests/backends/test_dense_output.py b/tests/backends/test_dense_output.py index 430a1435..68e44a52 100644 --- a/tests/backends/test_dense_output.py +++ b/tests/backends/test_dense_output.py @@ -28,13 +28,9 @@ from solprop.noise import modified_noise from solprop.output import MemoryOutput from solprop.output.hdf5 import resume -from solprop.output.schema import ( - INTERPOLATED_RESUMES_ANNOTATION, - PlaneStore, - StepLimits, - StepperRecord, - _example_metadata, -) +from solprop.output.metadata import INTERPOLATED_RESUMES_ANNOTATION, _example_metadata +from solprop.output.records import StepLimits, StepperRecord +from solprop.output.store import PlaneStore from solprop.stats import PLASMA_INTEGRATION_STATISTICS from solprop.stepper import DOPRI5, PIController, RungeKutta from solprop.stepper.multistep import Multistep diff --git a/tests/backends/test_flux_paths.py b/tests/backends/test_flux_paths.py index 1058fcf1..9a08e4e7 100644 --- a/tests/backends/test_flux_paths.py +++ b/tests/backends/test_flux_paths.py @@ -15,7 +15,7 @@ from solprop.driver.simulation import Simulation, SimulationSettings from solprop.exceptions import MaterialError, TranslationError from solprop.grid import EnvelopeGrid, RadialGrid -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.profiles import FromCallable, Linear from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_energy diff --git a/tests/backends/test_free_statistics.py b/tests/backends/test_free_statistics.py index c1c2e271..3f0849ea 100644 --- a/tests/backends/test_free_statistics.py +++ b/tests/backends/test_free_statistics.py @@ -16,7 +16,7 @@ from sessions.runs import launch_statistics from solprop.backends.session import ExecutionOptions from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_fluence from solprop.stats import StatsSpec, collect, default_spec diff --git a/tests/backends/test_held_plasma_contract.py b/tests/backends/test_held_plasma_contract.py index 2bf9f4da..0f092e55 100644 --- a/tests/backends/test_held_plasma_contract.py +++ b/tests/backends/test_held_plasma_contract.py @@ -16,7 +16,7 @@ from solprop.driver.simulation import _open_output, metadata_for from solprop.exceptions import DriverError, OutputError, TranslationError from solprop.output import HDF5Output, check_cache, resume -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.translate.plasma_source import _BENCHMARK_GATED, plasma_source_record field_grid = plasma_fixtures.field_grid diff --git a/tests/backends/test_kperp_collar.py b/tests/backends/test_kperp_collar.py index ff5ef54c..7ee35116 100644 --- a/tests/backends/test_kperp_collar.py +++ b/tests/backends/test_kperp_collar.py @@ -27,7 +27,7 @@ from solprop.driver import Simulation, SimulationSettings from solprop.grid import EnvelopeGrid, FreeGrid, LineGrid from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, PIController, RungeKutta diff --git a/tests/backends/test_low_storage.py b/tests/backends/test_low_storage.py index dcf385a5..c6d950f8 100644 --- a/tests/backends/test_low_storage.py +++ b/tests/backends/test_low_storage.py @@ -61,7 +61,7 @@ from solprop.noise import spatial_noise from solprop.nonlinear.responses import KerrResponse from solprop.output import MemoryOutput -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, beam_radius from solprop.stats import StatsSpec diff --git a/tests/backends/test_numpy_ref_backend.py b/tests/backends/test_numpy_ref_backend.py index f134c911..389de02a 100644 --- a/tests/backends/test_numpy_ref_backend.py +++ b/tests/backends/test_numpy_ref_backend.py @@ -27,7 +27,7 @@ from solprop.backends.sessions import start_session from solprop.exceptions import BackendError, TranslationError from solprop.noise import modified_noise -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.stats import StatsSpec, collect from solprop.stepper import FixedSchedule, PIController from solprop.translate.linop import unitary_phase diff --git a/tests/backends/test_radial_statistics.py b/tests/backends/test_radial_statistics.py index bbe18d61..7673b7f1 100644 --- a/tests/backends/test_radial_statistics.py +++ b/tests/backends/test_radial_statistics.py @@ -17,7 +17,7 @@ from solprop.backends.spatial import collect_spatial from solprop.exceptions import BackendError from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.profiles import Constant from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_fluence diff --git a/tests/backends/test_step_limits.py b/tests/backends/test_step_limits.py index 995e78fb..6942a4df 100644 --- a/tests/backends/test_step_limits.py +++ b/tests/backends/test_step_limits.py @@ -20,7 +20,8 @@ from solprop.driver import simulation as simulation_module from solprop.driver.simulation import Simulation from solprop.gasflow import steady_fill -from solprop.output.schema import StepLimits, StepperRecord, _example_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.records import StepLimits, StepperRecord from solprop.stepper import DOPRI5, Multistep, PIController from solprop.stepper.controller import START_GROWTH from solprop.translate.chebyshev import build_from_paths diff --git a/tests/backends/test_transverse_backends.py b/tests/backends/test_transverse_backends.py index d5090d3f..b33a3ccc 100644 --- a/tests/backends/test_transverse_backends.py +++ b/tests/backends/test_transverse_backends.py @@ -25,7 +25,7 @@ from solprop.exceptions import BoundaryWarning from solprop.grid import EnvelopeGrid, RadialGrid from solprop.monitor import BoundaryCheck -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, Multistep, PIController, RungeKutta diff --git a/tests/coupling/test_elements.py b/tests/coupling/test_elements.py index 91717db9..6d1e74c9 100644 --- a/tests/coupling/test_elements.py +++ b/tests/coupling/test_elements.py @@ -35,7 +35,8 @@ TaylorPhase, littrow_angle, ) -from solprop.output.schema import SavedPlane, serialise +from solprop.output.records import SavedPlane +from solprop.output.serialisation import serialise from solprop.profiles import Linear from solprop.spatial import SpatialFlux, SpatialMedium from solprop.stats import ApertureSet, StatsSpec, collect diff --git a/tests/coupling/test_plane.py b/tests/coupling/test_plane.py index ae04a03f..52dbfef2 100644 --- a/tests/coupling/test_plane.py +++ b/tests/coupling/test_plane.py @@ -28,7 +28,7 @@ from solprop.fourier import spectrum_to_optics from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid, RadialGrid from solprop.modes import MarcatiliMode -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_energy diff --git a/tests/driver/test_absorber_keywords.py b/tests/driver/test_absorber_keywords.py index 86c9f357..6cc1a586 100644 --- a/tests/driver/test_absorber_keywords.py +++ b/tests/driver/test_absorber_keywords.py @@ -253,7 +253,7 @@ def test_spatial_defaults_give_the_default_temporal_collar() -> None: def test_the_modal_interfaces_take_the_boundary_check() -> None: """``boundary_check=None`` on ``prop_gnlse`` runs with nothing watched (SEP 21).""" from solprop.driver import prop_gnlse # noqa: PLC0415 - from solprop.output.schema import PlaneStore # noqa: PLC0415 + from solprop.output.store import PlaneStore # noqa: PLC0415 run = prop_gnlse( 0.1, 0.01, [0.0, 0.0, -1e-26], wavelength=1e-6, fwhm=100e-15, peak_power=1.0, diff --git a/tests/driver/test_coupled_input_noise.py b/tests/driver/test_coupled_input_noise.py index fbde3d31..475043da 100644 --- a/tests/driver/test_coupled_input_noise.py +++ b/tests/driver/test_coupled_input_noise.py @@ -41,7 +41,7 @@ from solprop.constants import HBAR from solprop.driver import Simulation from solprop.noise import photons_per_bin -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing.axes import grid_of from solprop.stepper import FixedSchedule, RungeKutta diff --git a/tests/driver/test_dispersive_policy.py b/tests/driver/test_dispersive_policy.py index 2618f996..11d16c8a 100644 --- a/tests/driver/test_dispersive_policy.py +++ b/tests/driver/test_dispersive_policy.py @@ -23,7 +23,7 @@ from solprop.nonlinear.responses import DispersiveKerrResponse, KerrResponse from solprop.output import HDF5Output from solprop.output.hdf5 import check_cache -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.physdata import MaterialSelection, Mixture from solprop.physdata.nonlinear_data import OptmatKerr from solprop.pulses import GaussianPulse, SpatialPulse diff --git a/tests/driver/test_entrance_only_resume.py b/tests/driver/test_entrance_only_resume.py index 95c54a4b..68d7dd67 100644 --- a/tests/driver/test_entrance_only_resume.py +++ b/tests/driver/test_entrance_only_resume.py @@ -9,7 +9,7 @@ from solprop.driver import Simulation from solprop.output import MemoryOutput, open_file, resume -from solprop.output.schema import INTERPOLATED_RESUMES_ANNOTATION +from solprop.output.metadata import INTERPOLATED_RESUMES_ANNOTATION from solprop.stats import StatsSpec from solprop.stepper.multistep import Multistep diff --git a/tests/driver/test_field_free_planes.py b/tests/driver/test_field_free_planes.py index 944e06fe..538c2f31 100644 --- a/tests/driver/test_field_free_planes.py +++ b/tests/driver/test_field_free_planes.py @@ -34,7 +34,7 @@ from solprop.log import NullReporter from solprop.nonlinear.responses import KerrResponse from solprop.output import MemoryOutput, open_file -from solprop.output.schema import FieldRetention, FieldsChoice, Output, PlaneStore +from solprop.output.store import FieldRetention, FieldsChoice, Output, PlaneStore from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stats import default_spec diff --git a/tests/driver/test_initial_z_and_checkpoint.py b/tests/driver/test_initial_z_and_checkpoint.py index 5ab30447..f09a7034 100644 --- a/tests/driver/test_initial_z_and_checkpoint.py +++ b/tests/driver/test_initial_z_and_checkpoint.py @@ -48,7 +48,9 @@ resume, ) from solprop.output.hdf5 import ResumeState -from solprop.output.schema import Convention, RunMetadata, serialise +from solprop.output.metadata import RunMetadata +from solprop.output.records import Convention +from solprop.output.serialisation import serialise from solprop.profiles import PressureGradient from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium diff --git a/tests/driver/test_interface_defaults.py b/tests/driver/test_interface_defaults.py index 26d59f77..101291bb 100644 --- a/tests/driver/test_interface_defaults.py +++ b/tests/driver/test_interface_defaults.py @@ -46,7 +46,8 @@ from solprop.nonlinear.responses import KerrResponse from solprop.nonlinear.tdse import TDSEExecution from solprop.output.memory import MemoryOutput -from solprop.output.schema import DefaultsResolution, NoiseResolution, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import DefaultsResolution, NoiseResolution from solprop.physdata import gamma3 from solprop.pulses import GaussianPulse, PlanarPulse, SpatialPulse from solprop.spatial import SpatialMedium diff --git a/tests/driver/test_moving_guide.py b/tests/driver/test_moving_guide.py index 761dad80..b3869cf4 100644 --- a/tests/driver/test_moving_guide.py +++ b/tests/driver/test_moving_guide.py @@ -66,7 +66,7 @@ from solprop.nonlinear.normalisation import modal_kerr_coefficient from solprop.output.hdf5 import check_cache, resume from solprop.output.memory import MemoryOutput -from solprop.output.schema import ModeRecord +from solprop.output.records import ModeRecord from solprop.processing.modal import ( _plane_reference, _plane_rule, diff --git a/tests/driver/test_native_resume_history.py b/tests/driver/test_native_resume_history.py index d99267b8..88025632 100644 --- a/tests/driver/test_native_resume_history.py +++ b/tests/driver/test_native_resume_history.py @@ -46,7 +46,7 @@ from solprop.grid import EnvelopeGrid from solprop.output import open_file from solprop.output.hdf5 import resume -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stepper import FixedSchedule, LowStorageRungeKutta, Multistep, RungeKutta pytestmark = [ diff --git a/tests/driver/test_noise_default.py b/tests/driver/test_noise_default.py index 80043558..fc067d24 100644 --- a/tests/driver/test_noise_default.py +++ b/tests/driver/test_noise_default.py @@ -17,7 +17,8 @@ from solprop.exceptions import DriverError from solprop.grid import EnvelopeGrid, RadialGrid from solprop.noise import photons_per_bin -from solprop.output.schema import NoiseResolution, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import NoiseResolution from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import PIController diff --git a/tests/driver/test_optmat_rates.py b/tests/driver/test_optmat_rates.py index 192db16b..1f428e67 100644 --- a/tests/driver/test_optmat_rates.py +++ b/tests/driver/test_optmat_rates.py @@ -32,7 +32,7 @@ from solprop.nonlinear.responses import PlasmaResponse from solprop.output import open_file from solprop.output.hdf5 import check_cache -from solprop.output.schema import PEAK_FIELDS_ANNOTATION +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION from solprop.physdata import Mixture, species from solprop.physdata.selection import MaterialManifest, MaterialSelection from solprop.pulses import GaussianPulse, SpatialPulse diff --git a/tests/driver/test_plane_adoption.py b/tests/driver/test_plane_adoption.py index 090376e7..a796a50b 100644 --- a/tests/driver/test_plane_adoption.py +++ b/tests/driver/test_plane_adoption.py @@ -37,7 +37,8 @@ from solprop.exceptions import OutputError from solprop.fourier import power_amplitude from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import PlaneStore, SavedPlane, _adopted +from solprop.output.records import SavedPlane, _adopted +from solprop.output.store import PlaneStore from solprop.stepper import Multistep, RungeKutta from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.translate.linop import power_weight_at diff --git a/tests/driver/test_plasma_band_propagation.py b/tests/driver/test_plasma_band_propagation.py index 2d2708dc..21b62fbb 100644 --- a/tests/driver/test_plasma_band_propagation.py +++ b/tests/driver/test_plasma_band_propagation.py @@ -36,7 +36,7 @@ from solprop.grid import FieldGrid from solprop.nonlinear.mixtures import responses_for from solprop.nonlinear.responses import PlasmaResponse -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.profiles import Constant from solprop.pulses import GaussianPulse from solprop.stats import PLASMA_INTEGRATION_STATISTICS, StatsSpec diff --git a/tests/driver/test_prop_gnlse.py b/tests/driver/test_prop_gnlse.py index 4045f92b..e860188c 100644 --- a/tests/driver/test_prop_gnlse.py +++ b/tests/driver/test_prop_gnlse.py @@ -36,7 +36,7 @@ attenuation_from_db, ) from solprop.output import HDF5Output, MemoryOutput -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import Mixture from solprop.profiles import Linear from solprop.stepper import FixedSchedule diff --git a/tests/driver/test_prop_radial.py b/tests/driver/test_prop_radial.py index 6144ddbe..43415088 100644 --- a/tests/driver/test_prop_radial.py +++ b/tests/driver/test_prop_radial.py @@ -21,7 +21,7 @@ from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid from solprop.noise import spatial_noise from solprop.output.hdf5 import check_cache -from solprop.output.schema import RunMetadata +from solprop.output.metadata import RunMetadata from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_energy from solprop.stepper import PIController diff --git a/tests/driver/test_prop_slab.py b/tests/driver/test_prop_slab.py index c640f2db..d25927d7 100644 --- a/tests/driver/test_prop_slab.py +++ b/tests/driver/test_prop_slab.py @@ -49,7 +49,7 @@ ) from solprop.nonlinear.gnlse import SpecifiedMode from solprop.output.hdf5 import open_file -from solprop.output.schema import RunMetadata +from solprop.output.metadata import RunMetadata from solprop.physdata import n2 from solprop.physdata.selection import MaterialSelection from solprop.plotting import plot_energy, plot_stats, spec_1d, time_1d diff --git a/tests/driver/test_resume_gate.py b/tests/driver/test_resume_gate.py index 3be9805a..f4c3e12f 100644 --- a/tests/driver/test_resume_gate.py +++ b/tests/driver/test_resume_gate.py @@ -26,7 +26,7 @@ from solprop.exceptions import OutputError from solprop.log import NullReporter from solprop.output import check_cache, open_file, resumability -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.recipe import Recipe, resume_file pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning") diff --git a/tests/driver/test_resume_history.py b/tests/driver/test_resume_history.py index a5428b8c..a027fbe4 100644 --- a/tests/driver/test_resume_history.py +++ b/tests/driver/test_resume_history.py @@ -40,7 +40,8 @@ from solprop.log import NullReporter from solprop.output import HDF5Output from solprop.output.hdf5 import open_file, resume -from solprop.output.schema import MarchSnapshot, SavedPlane, _example_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.records import MarchSnapshot, SavedPlane from solprop.stepper import Multistep, RungeKutta # The fixtures here launch noise-free into time windows kept tight for speed, which diff --git a/tests/driver/test_run.py b/tests/driver/test_run.py index 642adfc5..3dcfff07 100644 --- a/tests/driver/test_run.py +++ b/tests/driver/test_run.py @@ -272,7 +272,7 @@ def read_file(path, key): def test_the_stored_history_holds_start_positions_and_per_attempt_times(): """The backend records end positions and a running clock; the file does not. - ``output.schema.StepHistory`` documents ``z`` as the start of each attempt and + ``output.records.StepHistory`` documents ``z`` as the start of each attempt and ``wall_time`` as the time one attempt took, and the driver converts on the way. Exactly representable numbers, so the check is bitwise. """ diff --git a/tests/driver/test_runner.py b/tests/driver/test_runner.py index b432b249..b3dcce2d 100644 --- a/tests/driver/test_runner.py +++ b/tests/driver/test_runner.py @@ -35,15 +35,10 @@ from solprop.exceptions import DriverError, TranslationError from solprop.log import NullReporter from solprop.noise import NoiseRealisation, spatial_noise -from solprop.output import MemoryOutput, open_file -from solprop.output import schema as output_schema -from solprop.output.schema import ( - PEAK_FIELDS_ANNOTATION, - ArrayDigest, - Output, - PlaneStore, - RunMetadata, -) +from solprop.output import MemoryOutput, open_file, serialisation +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION, RunMetadata +from solprop.output.serialisation import ArrayDigest +from solprop.output.store import Output, PlaneStore from solprop.pulses import GaussianPulse from solprop.spatial import SpatialFlux from solprop.stats import StatsSpec @@ -494,7 +489,7 @@ def test_with_input_validates_the_replaced_input_alone(): def test_a_large_array_input_is_recorded_by_its_digest(monkeypatch): simulation = capillary("numpy_ref") field = inputs(simulation)[0] - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", field.size - 1) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", field.size - 1) one = simulation.with_input(field).metadata() recorded = one.configuration["initial"] assert isinstance(recorded, ArrayDigest) @@ -505,7 +500,7 @@ def test_a_large_array_input_is_recorded_by_its_digest(monkeypatch): assert two.configuration["initial"] != recorded assert two.configuration_hash() != one.configuration_hash() # Below the limit an array is recorded inline, as before. - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", field.size) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", field.size) inline = simulation.with_input(field).metadata().configuration["initial"] assert isinstance(inline, np.ndarray) @@ -569,7 +564,7 @@ def test_a_runner_holds_no_input_through_the_march( """ simulation = capillary(backend) if route == "modal" else cartesian(backend) limit = 0 if digested else simulation._field0.size - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", limit) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", limit) run = unchecked(SimulationRunner.run) with simulation.runner() as runner: loads = _spied_load(runner.backend, monkeypatch) @@ -593,7 +588,7 @@ def test_a_runner_holds_no_input_through_the_march( def test_a_runner_reads_a_digested_input_without_a_copy(backend, monkeypatch): """The derived simulation views the caller's array, which it leaves as it was.""" simulation = cartesian(backend) - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) field = 0.9 * simulation._field0 before = field.copy() with simulation.runner() as runner: diff --git a/tests/driver/test_scan_field_retention.py b/tests/driver/test_scan_field_retention.py index ce295f64..6105e300 100644 --- a/tests/driver/test_scan_field_retention.py +++ b/tests/driver/test_scan_field_retention.py @@ -21,7 +21,7 @@ from solprop.driver._scan_run import _Capture, _Options, _retained from solprop.driver.scan import Scan, run_scan from solprop.exceptions import OutputError -from solprop.output.schema import FieldsChoice +from solprop.output.store import FieldsChoice from solprop.processing import scans diff --git a/tests/driver/test_scan_queue.py b/tests/driver/test_scan_queue.py index ff0fcd6e..5c0f6493 100644 --- a/tests/driver/test_scan_queue.py +++ b/tests/driver/test_scan_queue.py @@ -31,8 +31,9 @@ from solprop.driver.scan import Scan, run_scan from solprop.exceptions import DriverError, OutputError from solprop.output.hdf5 import _dataset +from solprop.output.metadata import _example_metadata from solprop.output.scan import PointResult -from solprop.output.schema import RECORD_ARRAY_LIMIT, _example_metadata +from solprop.output.serialisation import RECORD_ARRAY_LIMIT pytestmark = pytest.mark.skipif( sys.platform == "win32", diff --git a/tests/driver/test_scan_worker_validation.py b/tests/driver/test_scan_worker_validation.py index 69f7781d..70e13720 100644 --- a/tests/driver/test_scan_worker_validation.py +++ b/tests/driver/test_scan_worker_validation.py @@ -18,7 +18,7 @@ from solprop.driver._scan_run import _Capture, _checked, _Job, _Options, _Work from solprop.driver.scan import Scan, run_scan from solprop.exceptions import DriverError, OutputError -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.processing import scans diff --git a/tests/driver/test_simulation.py b/tests/driver/test_simulation.py index fa2a0eef..4420ea1e 100644 --- a/tests/driver/test_simulation.py +++ b/tests/driver/test_simulation.py @@ -292,7 +292,7 @@ def test_a_capillary_configuration_hashes_the_same_way_in_a_new_process(): Every object the configuration carries -- the modes, the pressure and density paths, the responses, the rate object and the pulse -- has to serialise to its own data rather than to a ``repr`` holding a memory address - (:func:`solprop.output.schema.serialise`). + (:func:`solprop.output.serialisation.serialise`). """ program = ( "import warnings, sys\n" diff --git a/tests/driver/test_snapshots.py b/tests/driver/test_snapshots.py index 27759bb1..f9058153 100644 --- a/tests/driver/test_snapshots.py +++ b/tests/driver/test_snapshots.py @@ -39,7 +39,7 @@ resume, ) from solprop.output import snapshots as module -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.stepper import Multistep, PIController, RungeKutta pytestmark = [ diff --git a/tests/driver/test_stated_input.py b/tests/driver/test_stated_input.py index 41fbd35a..26d164fe 100644 --- a/tests/driver/test_stated_input.py +++ b/tests/driver/test_stated_input.py @@ -21,9 +21,9 @@ from solprop.driver.executors import Local from solprop.driver.scan import Scan, run_scan from solprop.exceptions import DriverError, OutputError -from solprop.output import open_file -from solprop.output import schema as output_schema -from solprop.output.schema import ArrayDigest, StatedInput, _example_metadata +from solprop.output import open_file, serialisation +from solprop.output.metadata import _example_metadata +from solprop.output.serialisation import ArrayDigest, StatedInput from solprop.physdata import Mixture from solprop.pulses import GaussianPulse @@ -36,8 +36,8 @@ def refuse(values: np.ndarray) -> ArrayDigest: msg = f"a digest of {values.shape} was taken" raise AssertionError(msg) - monkeypatch.setattr(output_schema.ArrayDigest, "of", refuse) - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation.ArrayDigest, "of", refuse) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) @pytest.mark.usefixtures("no_digest") @@ -69,7 +69,7 @@ def test_the_hash_follows_the_statement_and_not_the_bytes(): def test_without_a_statement_a_large_array_is_digested_as_ever(monkeypatch): simulation = capillary("numpy_ref") field = inputs(simulation)[0] - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) recorded = simulation.with_input(field).metadata().configuration["initial"] assert recorded == ArrayDigest.of(field) @@ -116,7 +116,7 @@ def test_a_runner_digests_each_input_once_and_never_its_parent(monkeypatch): """Without a statement: a parent's digest would read a placeholder at full size.""" simulation = capillary("numpy_ref") fields = inputs(simulation) - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) digest = ArrayDigest.of digested: list[tuple[int, ...]] = [] @@ -124,7 +124,7 @@ def counting(values: np.ndarray) -> ArrayDigest: digested.append(values.shape) return digest(values) - monkeypatch.setattr(output_schema.ArrayDigest, "of", counting) + monkeypatch.setattr(serialisation.ArrayDigest, "of", counting) with simulation.runner() as runner: for index, field in enumerate(fields): runner.run(field, configuration={"point": index}, progress="none") diff --git a/tests/driver/test_summary.py b/tests/driver/test_summary.py index 0ab96595..d079a1fb 100644 --- a/tests/driver/test_summary.py +++ b/tests/driver/test_summary.py @@ -47,7 +47,8 @@ from solprop.nonlinear.raman import SilicaTensorResponse from solprop.nonlinear.responses import KerrResponse, PlasmaResponse from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import RUN_SUMMARY_ANNOTATION, GitProvenance +from solprop.output.metadata import RUN_SUMMARY_ANNOTATION +from solprop.output.provenance import GitProvenance from solprop.physdata import Mixture from solprop.profiles import Linear from solprop.pulses import GaussianPulse, PlanarPulse, SpatialPulse diff --git a/tests/driver/test_tdse_entry.py b/tests/driver/test_tdse_entry.py index b81b62b0..552a14e6 100644 --- a/tests/driver/test_tdse_entry.py +++ b/tests/driver/test_tdse_entry.py @@ -36,7 +36,7 @@ TDSESampling, ) from solprop.output import MemoryOutput -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.stats import StatsSpec from solprop.translate.routes import tdse_descriptor from solprop.translate.tdse_source import TDSE_UNAVAILABLE_STATISTICS diff --git a/tests/driver/test_tdse_hacc_run.py b/tests/driver/test_tdse_hacc_run.py index c3f3b132..ef8f9328 100644 --- a/tests/driver/test_tdse_hacc_run.py +++ b/tests/driver/test_tdse_hacc_run.py @@ -40,7 +40,7 @@ ) from solprop.nonlinear.tdse import TDSEConsistency, TDSEExecution from solprop.output import MemoryOutput, open_file, resume -from solprop.output.schema import SCHEMA_VERSION +from solprop.output.metadata import SCHEMA_VERSION from solprop.stats import StatsSpec pytestmark = pytest.mark.medium diff --git a/tests/driver/test_temperature_paths.py b/tests/driver/test_temperature_paths.py index 9f3d532c..ce4b7909 100644 --- a/tests/driver/test_temperature_paths.py +++ b/tests/driver/test_temperature_paths.py @@ -26,7 +26,7 @@ ) from solprop.gasflow import steady_fill from solprop.modes import MarcatiliMode -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import Mixture, species from solprop.profiles import Constant, Linear, PressureGradient from solprop.spatial import SpatialMedium diff --git a/tests/driver/test_vincetti.py b/tests/driver/test_vincetti.py index 309a603e..9404b7e8 100644 --- a/tests/driver/test_vincetti.py +++ b/tests/driver/test_vincetti.py @@ -25,7 +25,7 @@ from solprop.modes.quadrature import mode_matrix, transverse_quadrature from solprop.output import open_file from solprop.output.hdf5 import resume -from solprop.output.schema import ModeRecord +from solprop.output.records import ModeRecord from solprop.physdata import Component, Mixture, Species, TabulatedSusceptibility from solprop.profiles import Constant from solprop.stats import StatsSpec diff --git a/tests/driver/test_zero_rate_pass.py b/tests/driver/test_zero_rate_pass.py index dd020613..8dc1ba94 100644 --- a/tests/driver/test_zero_rate_pass.py +++ b/tests/driver/test_zero_rate_pass.py @@ -24,7 +24,7 @@ from solprop.driver.run import run_problem from solprop.exceptions import TranslationError from solprop.output import MemoryOutput -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise def spectral_only(backend: str, scheme: str, **changes): diff --git a/tests/modes/test_field_scale.py b/tests/modes/test_field_scale.py index eb0e9617..edfcbf92 100644 --- a/tests/modes/test_field_scale.py +++ b/tests/modes/test_field_scale.py @@ -388,7 +388,7 @@ def capillary_simulation(radial_rule, n_r): def test_the_run_records_its_rule_and_a_changed_rule_changes_the_resume_key(): - from solprop.output.schema import RunMetadata # noqa: PLC0415 - the output layer + from solprop.output.metadata import RunMetadata # noqa: PLC0415 - the output layer field_scale = capillary_simulation("field_scale_radau", 5).metadata() default = capillary_simulation("gauss_kronrod", 32).metadata() diff --git a/tests/modes/test_stepindex_quadrature.py b/tests/modes/test_stepindex_quadrature.py index fc0e7aed..9425afaa 100644 --- a/tests/modes/test_stepindex_quadrature.py +++ b/tests/modes/test_stepindex_quadrature.py @@ -16,7 +16,7 @@ from solprop.modes import StepIndexMode from solprop.modes.collection import overlap from solprop.modes.quadrature import mode_matrix, quadrature_rule, transverse_quadrature -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise @dataclass(frozen=True, eq=False) diff --git a/tests/modes/test_tabulated.py b/tests/modes/test_tabulated.py index adddab2f..09334201 100644 --- a/tests/modes/test_tabulated.py +++ b/tests/modes/test_tabulated.py @@ -18,7 +18,7 @@ from solprop.constants import C from solprop.exceptions import ModeError from solprop.modes import IndexTable, MarcatiliMode, TabulatedHollowMode, TabulatedMode -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata.glasses import GlassIndex from solprop.physdata.mixtures import Mixture from solprop.profiles import Constant, Linear, PressureGradient diff --git a/tests/nonlinear/test_gnlse.py b/tests/nonlinear/test_gnlse.py index f30dd2b3..b918dc65 100644 --- a/tests/nonlinear/test_gnlse.py +++ b/tests/nonlinear/test_gnlse.py @@ -24,7 +24,7 @@ problem_from, raman_susceptibility, ) -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata.glasses import SilicaBundle from solprop.profiles import Linear diff --git a/tests/nonlinear/test_gnlse_selection.py b/tests/nonlinear/test_gnlse_selection.py index 4d33f96a..bf51f6bc 100644 --- a/tests/nonlinear/test_gnlse_selection.py +++ b/tests/nonlinear/test_gnlse_selection.py @@ -22,7 +22,7 @@ raman_susceptibility, ) from solprop.nonlinear.raman import SampledShape -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata.glasses import SilicaBundle from solprop.physdata.selection import MaterialSelection diff --git a/tests/nonlinear/test_optmat_raman.py b/tests/nonlinear/test_optmat_raman.py index a100cccd..bb458b29 100644 --- a/tests/nonlinear/test_optmat_raman.py +++ b/tests/nonlinear/test_optmat_raman.py @@ -47,7 +47,7 @@ mixture_raman, ) from solprop.nonlinear.raman import RamanResponse, RamanTensorTerm, response_for -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import Component, Gas, Mixture, Species, gamma, species from solprop.physdata import _optmat as bridge from solprop.physdata.raman_types import Dephasing, Linewidth, MolecularOptions diff --git a/tests/nonlinear/test_vector_raman.py b/tests/nonlinear/test_vector_raman.py index ec6f6f5c..d9606fca 100644 --- a/tests/nonlinear/test_vector_raman.py +++ b/tests/nonlinear/test_vector_raman.py @@ -42,7 +42,7 @@ response_for, undamped_dephasing, ) -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import MolecularRaman from vector_raman_declaration import Declaration from vector_raman_tensor_oracle import ( diff --git a/tests/output/test_callback.py b/tests/output/test_callback.py index b9705f3e..164457e7 100644 --- a/tests/output/test_callback.py +++ b/tests/output/test_callback.py @@ -41,17 +41,16 @@ check_cache, resumability, resume, + serialisation, ) -from solprop.output import schema as output_schema -from solprop.output.schema import ( - PEAK_FIELDS_ANNOTATION, +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION, _example_metadata +from solprop.output.records import SavedPlane +from solprop.output.store import ( CheckpointOutput, FieldRetention, Output, PlaneStore, RetainingOutput, - SavedPlane, - _example_metadata, ) from solprop.plotting.style import field_modes from solprop.processing import energy @@ -418,7 +417,7 @@ def test_a_reducing_run_keeps_no_more_alive_as_it_goes(backend, monkeypatch): control, which shows that the measure can see a volume at all: it adds one per plane, so three here, of which the bound takes a tenth off for what a pass frees. """ - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) volume = cartesian(backend)._field0.nbytes copies: list[np.ndarray] = [] diff --git a/tests/output/test_checkpoints.py b/tests/output/test_checkpoints.py index 6b546f3f..8a0f40ea 100644 --- a/tests/output/test_checkpoints.py +++ b/tests/output/test_checkpoints.py @@ -27,11 +27,8 @@ resume, ) from solprop.output.hdf5 import _dataset, _write_checkpoint -from solprop.output.schema import ( - StepStats, - _example_metadata, - _prepend_checkpoint_history, -) +from solprop.output.metadata import _example_metadata +from solprop.output.records import StepStats, _prepend_checkpoint_history @pytest.fixture diff --git a/tests/output/test_field_destination.py b/tests/output/test_field_destination.py index 9637f16b..d933a438 100644 --- a/tests/output/test_field_destination.py +++ b/tests/output/test_field_destination.py @@ -6,10 +6,10 @@ A run's session exports a kept field straight into the stored form, the true-power amplitude in the optics convention, into the room the store gives it -(:meth:`~solprop.output.schema.PlaneStore.field_destination`): ``MemoryOutput``'s +(:meth:`~solprop.output.store.PlaneStore.field_destination`): ``MemoryOutput``'s own block row, so the field is written once where it is kept, or a new array for -``HDF5Output``. :meth:`~solprop.output.schema.PlaneStore.save_exported` then stores -exactly what :meth:`~solprop.output.schema.PlaneStore.save` would have stored for the +``HDF5Output``. :meth:`~solprop.output.store.PlaneStore.save_exported` then stores +exactly what :meth:`~solprop.output.store.PlaneStore.save` would have stored for the same field: equalities here are exact, structural contracts of lossless storage. """ @@ -21,7 +21,8 @@ from solprop.exceptions import OutputError from solprop.grid import EnvelopeGrid from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import FieldRetention, SavedPlane, StoredField +from solprop.output.records import SavedPlane, StoredField +from solprop.output.store import FieldRetention from solprop.stepper import FixedSchedule diff --git a/tests/output/test_field_retention.py b/tests/output/test_field_retention.py index be6cd678..96a72692 100644 --- a/tests/output/test_field_retention.py +++ b/tests/output/test_field_retention.py @@ -22,18 +22,18 @@ from solprop.exceptions import DriverError, OutputError from solprop.output import HDF5Output, MemoryOutput, Output, open_file from solprop.output.hdf5 import resume -from solprop.output.schema import ( +from solprop.output.metadata import _example_metadata +from solprop.output.records import RunOutcome, SavedPlane, TerminalCheckpoint +from solprop.output.serialisation import ( INLINE_ARRAY_LIMIT, ArrayDigest, + recorded_input, + serialise, +) +from solprop.output.store import ( FieldRetention, RetainingOutput, - RunOutcome, - SavedPlane, - TerminalCheckpoint, - _example_metadata, field_retention_refusal, - recorded_input, - serialise, ) from solprop.stats import collect, default_spec @@ -380,7 +380,7 @@ def test_an_array_above_the_inline_limit_is_recorded_by_its_digest(): assert ArrayDigest.of(numbers.astype(">f8")) == ArrayDigest.of(numbers) record = serialise(digest) assert isinstance(record, dict) - assert record["__class__"] == "solprop.output.schema.ArrayDigest" + assert record["__class__"] == "solprop.output.serialisation.ArrayDigest" assert len(json.dumps(record)) < 300 diff --git a/tests/output/test_handlers.py b/tests/output/test_handlers.py index 86395357..fe3e531e 100644 --- a/tests/output/test_handlers.py +++ b/tests/output/test_handlers.py @@ -33,13 +33,11 @@ resume, ) from solprop.output import hdf5 as hdf5_module -from solprop.output.schema import ( - PEAK_FIELDS_ANNOTATION, - SCHEMA_VERSION, - BackendRecord, +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION, SCHEMA_VERSION, RunMetadata +from solprop.output.provenance import BackendRecord +from solprop.output.records import ( GridRecord, NoiseRecord, - RunMetadata, SavedPlane, StepHistory, StepLimits, diff --git a/tests/output/test_march_snapshot_layout.py b/tests/output/test_march_snapshot_layout.py index cd27e0cf..99d8a685 100644 --- a/tests/output/test_march_snapshot_layout.py +++ b/tests/output/test_march_snapshot_layout.py @@ -16,7 +16,7 @@ import pytest from solprop.output.hdf5 import _read_march, _write_march -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot def snapshot(state): diff --git a/tests/output/test_material_resume.py b/tests/output/test_material_resume.py index 2670bff5..e2a9251d 100644 --- a/tests/output/test_material_resume.py +++ b/tests/output/test_material_resume.py @@ -24,8 +24,9 @@ from solprop.grid import FieldGrid from solprop.output import HDF5Output, MemoryOutput, open_file from solprop.output.hdf5 import check_cache, resume +from solprop.output.metadata import SCHEMA_VERSION, RunMetadata +from solprop.output.records import SavedPlane from solprop.output.scan import PointResult, ScanWriter -from solprop.output.schema import SCHEMA_VERSION, RunMetadata, SavedPlane from solprop.physdata import _optmat from solprop.physdata.selection import MaterialSelection diff --git a/tests/output/test_modal_resume.py b/tests/output/test_modal_resume.py index 047dec94..6108bb18 100644 --- a/tests/output/test_modal_resume.py +++ b/tests/output/test_modal_resume.py @@ -36,13 +36,8 @@ from solprop.output import HDF5Output from solprop.output.hdf5 import check_cache, open_file, resume from solprop.output.memory import MemoryOutput -from solprop.output.schema import ( - SCHEMA_VERSION, - ModeRecord, - RunMetadata, - SavedPlane, - _example_metadata, -) +from solprop.output.metadata import SCHEMA_VERSION, RunMetadata, _example_metadata +from solprop.output.records import ModeRecord, SavedPlane from solprop.processing import scans from solprop.processing.modal import channel_labels, physical_energy from solprop.stats import GENERAL_MODAL_ACCESSORS diff --git a/tests/output/test_power_convention.py b/tests/output/test_power_convention.py index 4e8f2a6e..a6de8b3a 100644 --- a/tests/output/test_power_convention.py +++ b/tests/output/test_power_convention.py @@ -12,7 +12,8 @@ from solprop.fourier import power_amplitude from solprop.output import HDF5Output from solprop.output.hdf5 import check_cache, resume -from solprop.output.schema import SavedPlane, _example_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.records import SavedPlane def test_the_hash_and_field_attribute_record_the_power_convention(tmp_path): diff --git a/tests/output/test_preallocation.py b/tests/output/test_preallocation.py index 88bce406..7d1817b9 100644 --- a/tests/output/test_preallocation.py +++ b/tests/output/test_preallocation.py @@ -25,9 +25,9 @@ from solprop.driver.estimate import _catalogued_entries, plane_bytes from solprop.grid import EnvelopeGrid from solprop.output import HDF5Output, MemoryOutput, open_file, resume -from solprop.output.schema import ( +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( GridRecord, - RunMetadata, SavedPlane, StepHistory, StepperRecord, @@ -309,7 +309,8 @@ def test_a_hard_stop_part_way_through_a_plane_loses_only_that_plane(metadata, tm import os import numpy as np from solprop.output import HDF5Output - from solprop.output.schema import RunMetadata, SavedPlane + from solprop.output.metadata import RunMetadata + from solprop.output.records import SavedPlane metadata = RunMetadata.from_json({metadata.to_json()!r}) n_omega = metadata.grid.n_omega diff --git a/tests/output/test_recipe_group.py b/tests/output/test_recipe_group.py index 9e863e6d..f63669ff 100644 --- a/tests/output/test_recipe_group.py +++ b/tests/output/test_recipe_group.py @@ -7,7 +7,7 @@ import h5py from solprop.output.hdf5 import read_recipe, write_recipe -from solprop.output.schema import RecipeFile, RecipeRecord +from solprop.output.records import RecipeFile, RecipeRecord def test_a_recipe_and_its_files_read_back_as_written(tmp_path): diff --git a/tests/output/test_record_arrays.py b/tests/output/test_record_arrays.py index bc42c691..aea0b696 100644 --- a/tests/output/test_record_arrays.py +++ b/tests/output/test_record_arrays.py @@ -23,8 +23,9 @@ from solprop.nonlinear.responses import KerrResponse from solprop.output import HDF5Output, check_cache, open_file from solprop.output.hdf5 import _read_record, _write_record +from solprop.output.metadata import RunMetadata, _example_metadata from solprop.output.scan import PointResult, ScanWriter, read_point -from solprop.output.schema import RECORD_ARRAY_LIMIT, RunMetadata, _example_metadata +from solprop.output.serialisation import RECORD_ARRAY_LIMIT from solprop.processing.planes import field_plane from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium diff --git a/tests/output/test_record_reuse.py b/tests/output/test_record_reuse.py index 39bba32f..2545bec5 100644 --- a/tests/output/test_record_reuse.py +++ b/tests/output/test_record_reuse.py @@ -6,20 +6,22 @@ import logging -from solprop.output import MemoryOutput, schema -from solprop.output.schema import _example_metadata +from solprop.output import MemoryOutput +from solprop.output import metadata as run_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.provenance import BackendRecord def test_the_hashed_part_is_serialised_once_and_handed_out_as_copies(monkeypatch): metadata = _example_metadata() calls = [] - serialise = schema.serialise + serialise = run_metadata.serialise def counted(value): calls.append(1) return serialise(value) - monkeypatch.setattr(schema, "serialise", counted) + monkeypatch.setattr(run_metadata, "serialise", counted) first = metadata.hashed() after_first = len(calls) second = metadata.hashed() @@ -52,14 +54,14 @@ def test_a_run_s_execution_facts_keep_its_resume_key(monkeypatch): metadata = _example_metadata() key = metadata.configuration_hash() calls = [] - serialise = schema.serialise + serialise = run_metadata.serialise def counted(value): calls.append(1) return serialise(value) - monkeypatch.setattr(schema, "serialise", counted) - backend = schema.BackendRecord(name="native", propagator_mode="tabulate") + monkeypatch.setattr(run_metadata, "serialise", counted) + backend = BackendRecord(name="native", propagator_mode="tabulate") executed = metadata.with_backend(backend) assert executed.configuration_hash() == key assert executed.hashed() == metadata.hashed() diff --git a/tests/output/test_scan.py b/tests/output/test_scan.py index 1dfebfc4..3b7cf0ca 100644 --- a/tests/output/test_scan.py +++ b/tests/output/test_scan.py @@ -23,8 +23,8 @@ from solprop.output._file_lock import lock, unlock from solprop.output._scan_lock import lock_path, read_scan from solprop.output.hdf5 import _dataset, _group +from solprop.output.metadata import _example_metadata from solprop.output.scan import PointResult, ScanWriter, _hash, _json, read_point -from solprop.output.schema import _example_metadata from solprop.processing.scans import ScanOutput from solprop.processing.scans import open as open_scan diff --git a/tests/output/test_scan_artifacts.py b/tests/output/test_scan_artifacts.py index 55c0a399..c95bdba3 100644 --- a/tests/output/test_scan_artifacts.py +++ b/tests/output/test_scan_artifacts.py @@ -14,9 +14,10 @@ from output.test_scan import result from solprop.exceptions import OutputError from solprop.output.hdf5 import _dataset, _group +from solprop.output.metadata import RunMetadata, _example_metadata from solprop.output.scan import PointResult from solprop.output.scan_artifacts import read_artifact, write_artifact -from solprop.output.schema import RECORD_ARRAY_LIMIT, RunMetadata, _example_metadata +from solprop.output.serialisation import RECORD_ARRAY_LIMIT @pytest.mark.parametrize( diff --git a/tests/output/test_schema.py b/tests/output/test_schema.py index 220ccc3e..648ce5cd 100644 --- a/tests/output/test_schema.py +++ b/tests/output/test_schema.py @@ -32,25 +32,23 @@ from solprop.nonlinear.gnlse import problem_from from solprop.nonlinear.raman import RamanResponse, response_for from solprop.nonlinear.responses import KerrResponse -from solprop.output import schema -from solprop.output.schema import ( +from solprop.output import metadata as run_metadata +from solprop.output import provenance, serialisation +from solprop.output.metadata import SCHEMA_VERSION, RunMetadata, resolve_metadata_write +from solprop.output.provenance import GitProvenance, package_versions +from solprop.output.records import ( FIELD_LAYOUT, - SCHEMA_VERSION, SPECTRAL_CONVENTION, - GitProvenance, GridRecord, ModeRecord, NoiseRecord, - RunMetadata, SavedPlane, StepHistory, StepLimits, StepperRecord, StepStats, - package_versions, - resolve_metadata_write, - serialise, ) +from solprop.output.serialisation import serialise from solprop.physdata import aluminium, fused_silica from solprop.physdata.gases import Gas from solprop.physdata.selection import MaterialSelection @@ -392,15 +390,15 @@ def test_package_versions_are_read_once_and_handed_out_as_copies(): first = package_versions() first["numpy"] = "changed" assert package_versions()["numpy"] != "changed" - assert schema._installed_versions.cache_info().hits > 0 + assert provenance._installed_versions.cache_info().hits > 0 @pytest.fixture def fresh_provenance(): """Forget whether the checkout tracks the package, asked once per process.""" - schema._tracks.cache_clear() + provenance._tracks.cache_clear() yield - schema._tracks.cache_clear() + provenance._tracks.cache_clear() def _status(branch: str = "main", dirty: bool = False) -> str: @@ -445,7 +443,7 @@ def test_git_provenance_reads_one_status_per_run( def answer(arguments, **kwargs): calls.append(arguments[1]) - text = "schema.py" if arguments[1] == "ls-files" else _status(branch, dirty) + text = "provenance.py" if arguments[1] == "ls-files" else _status(branch, dirty) return subprocess.CompletedProcess(arguments, 0, stdout=text, stderr="") monkeypatch.setattr(subprocess, "run", answer) @@ -460,7 +458,7 @@ def test_git_provenance_is_none_without_a_commit(monkeypatch, fresh_provenance): # A repository before its first commit reports "(initial)" for the commit. def answer(arguments, **kwargs): text = ( - "schema.py" + "provenance.py" if arguments[1] == "ls-files" else "# branch.oid (initial)\n# branch.head main" ) @@ -474,7 +472,7 @@ def test_git_provenance_is_none_when_status_fails(monkeypatch, fresh_provenance) def answer(arguments, **kwargs): code = 0 if arguments[1] == "ls-files" else 128 return subprocess.CompletedProcess( - arguments, code, stdout="schema.py", stderr="" + arguments, code, stdout="provenance.py", stderr="" ) monkeypatch.setattr(subprocess, "run", answer) @@ -677,8 +675,8 @@ def test_metadata_hash_is_stable_across_processes(envelope_grid, stepper): [ "import numpy as np", "from solprop.grid import EnvelopeGrid", - "from solprop.output.schema import GridRecord, RunMetadata, StepLimits", - "from solprop.output.schema import StepperRecord", + "from solprop.output.metadata import RunMetadata", + "from solprop.output.records import GridRecord, StepLimits, StepperRecord", "from solprop.stepper import DOPRI5, FixedSchedule, PIController", "grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12)", "stepper = StepperRecord.from_settings(", @@ -818,7 +816,7 @@ def test_overwrite_rule_refuses_a_different_run_and_names_the_field( def test_overwrite_rule_overwrites_and_logs_when_forced(envelope_grid, stepper, caplog): one = make_metadata(envelope_grid, stepper) two = make_metadata(envelope_grid, stepper, configuration={"gamma": 0.22}) - with caplog.at_level("WARNING", logger=schema.__name__): + with caplog.at_level("WARNING", logger=run_metadata.__name__): assert resolve_metadata_write(one, two, force=True) is True assert "configuration.gamma" in caplog.text @@ -844,7 +842,8 @@ def test_tabulated_mode_metadata_is_reproducible(class_name, envelope_grid, step script = f""" import numpy as np from solprop.modes import IndexTable, {class_name} -from solprop.output.schema import ModeRecord, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import ModeRecord metadata = RunMetadata.from_json({metadata.to_json()!r}) table = IndexTable.from_wavelength( [400e-9, 600e-9, 800e-9, 1000e-9], [1.001 + 1e-8j] * 4, area=[1e-10] * 4) @@ -878,7 +877,8 @@ def test_rectangular_and_stepindex_records_round_trip_and_hash_alike( script = f""" from dataclasses import replace from solprop.modes import RectMode, StepIndexMode -from solprop.output.schema import ModeRecord, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import ModeRecord metadata = RunMetadata.from_json({metadata.to_json()!r}) records = ( ModeRecord.from_mode(RectMode(50e-6, 10e-6, "Ar", 5., cladding=1.45)), @@ -926,7 +926,8 @@ def test_a_metal_wall_record_round_trips_and_hashes_alike_across_processes( script = f""" from dataclasses import replace from solprop.modes import RectMode -from solprop.output.schema import ModeRecord, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import ModeRecord from solprop.physdata.metals import aluminium metadata = RunMetadata.from_json({metadata.to_json()!r}) record = ModeRecord.from_mode(RectMode(50e-6, 10e-6, "Ar", 5., cladding=aluminium())) @@ -976,7 +977,7 @@ def test_cubic_spline_metadata_survives_independent_construction( assert "reproducible" not in record script = ( "import json, numpy as np; from scipy.interpolate import CubicSpline; " - "from solprop.output.schema import serialise; " + "from solprop.output.serialisation import serialise; " "x = np.linspace(0.0, 1.0, 5); " "print(json.dumps(serialise(CubicSpline(x, np.sin(x), extrapolate=False))))" ) @@ -1045,7 +1046,7 @@ def test_spatial_metadata_names_its_flux_model(envelope_grid, stepper): def test_inlined_replaces_every_array_reference_by_its_values(): - inlined = schema._inlined(schema.serialise({"axis": np.zeros(5000)})) + inlined = serialisation._inlined(serialisation.serialise({"axis": np.zeros(5000)})) assert isinstance(inlined, dict) axis = inlined["axis"] assert isinstance(axis, dict) @@ -1053,23 +1054,23 @@ def test_inlined_replaces_every_array_reference_by_its_values(): def test_array_name_carries_the_dtype_and_the_shape(): - digest = schema.ArrayDigest.of(np.zeros((50, 100))) - assert schema._array_name(digest).split("-")[1:] == ["float64", "50x100"] + digest = serialisation.ArrayDigest.of(np.zeros((50, 100))) + assert serialisation._array_name(digest).split("-")[1:] == ["float64", "50x100"] def test_dotted_writes_keys_with_dots_and_indices_in_brackets(): - assert schema._dotted(("modes", 2, "label")) == "modes[2].label" + assert serialisation._dotted(("modes", 2, "label")) == "modes[2].label" def test_flow_text_names_a_diagonal_flow_and_summarises_a_coupled_one(): - assert schema._flow_text(None) == "diagonal" + assert run_metadata._flow_text(None) == "diagonal" flow = { "model": "mean impedance coherent wall", "section_methods": ["constant_schur"], "transverse_ratio": 9.012e-3, "inverse_condition_bound": 1.0, } - assert schema._flow_text(flow) == ( + assert run_metadata._flow_text(flow) == ( "mean impedance coherent wall, constant_schur, worst ratio 9.012e-03, " "conditioning 1.000e+00" ) diff --git a/tests/output/test_snapshots.py b/tests/output/test_snapshots.py index 1c66d35b..971b6a54 100644 --- a/tests/output/test_snapshots.py +++ b/tests/output/test_snapshots.py @@ -25,7 +25,8 @@ ) from solprop.output import snapshots as module from solprop.output._writer_lock import WriterLock -from solprop.output.schema import GitProvenance, _example_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.provenance import GitProvenance @pytest.fixture diff --git a/tests/plotting/test_statistics.py b/tests/plotting/test_statistics.py index c8af3753..0fb76b47 100644 --- a/tests/plotting/test_statistics.py +++ b/tests/plotting/test_statistics.py @@ -7,13 +7,8 @@ import pytest from solprop.output import MemoryOutput -from solprop.output.schema import ( - GridRecord, - RunMetadata, - SavedPlane, - StepperRecord, - StepStats, -) +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, SavedPlane, StepperRecord, StepStats from solprop.plotting.statistics import _should_log, _trace, plot_stats from solprop.processing._examples import example_grid, example_output, gvd_phase from solprop.pulses import GaussianPulse diff --git a/tests/plotting/test_style.py b/tests/plotting/test_style.py index ae0b44d3..02ec9793 100644 --- a/tests/plotting/test_style.py +++ b/tests/plotting/test_style.py @@ -11,7 +11,8 @@ from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.output import MemoryOutput -from solprop.output.schema import GridRecord, ModeRecord, RunMetadata, StepperRecord +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, ModeRecord, StepperRecord from solprop.plotting import style from solprop.processing._examples import example_grid, example_output from solprop.processing.axes import _check_choice diff --git a/tests/processing/conftest.py b/tests/processing/conftest.py index 9cafc580..4bcd9f8b 100644 --- a/tests/processing/conftest.py +++ b/tests/processing/conftest.py @@ -17,13 +17,8 @@ from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import ( - GridRecord, - ModeRecord, - RunMetadata, - SavedPlane, - StepperRecord, -) +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, ModeRecord, SavedPlane, StepperRecord from solprop.pulses import GaussianPulse from solprop.stats import collect, default_spec diff --git a/tests/processing/test_axes.py b/tests/processing/test_axes.py index 98c58ae4..7fb46da0 100644 --- a/tests/processing/test_axes.py +++ b/tests/processing/test_axes.py @@ -12,7 +12,8 @@ from solprop.exceptions import GridError, ModeError, OutputError, ProcessingError from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid from solprop.output import MemoryOutput -from solprop.output.schema import Convention, FieldRetention, GridRecord +from solprop.output.records import Convention, GridRecord +from solprop.output.store import FieldRetention from solprop.processing import axes from solprop.processing._examples import example_grid, example_output, gvd_phase diff --git a/tests/processing/test_mixtures.py b/tests/processing/test_mixtures.py index 7862106c..62f27fc3 100644 --- a/tests/processing/test_mixtures.py +++ b/tests/processing/test_mixtures.py @@ -17,7 +17,7 @@ from solprop.exceptions import GasFlowWarning, ProcessingError from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.physdata import Mixture from solprop.processing._examples import example_output from solprop.processing.fields import _spectral_density diff --git a/tests/processing/test_noise_floor.py b/tests/processing/test_noise_floor.py index ac143a1f..b52fe184 100644 --- a/tests/processing/test_noise_floor.py +++ b/tests/processing/test_noise_floor.py @@ -20,7 +20,7 @@ from solprop.driver import Simulation from solprop.exceptions import ProcessingError -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.plotting.propagation import prop_2d, spec_1d from solprop.processing import get_spectral_density, get_spectrum from solprop.processing.axes import _check_choice, grid_of diff --git a/tests/processing/test_planes.py b/tests/processing/test_planes.py index 8d024032..807f9546 100644 --- a/tests/processing/test_planes.py +++ b/tests/processing/test_planes.py @@ -26,7 +26,7 @@ ) from solprop.output.hdf5 import open_file from solprop.output.memory import MemoryOutput -from solprop.output.schema import SavedPlane, StepHistory +from solprop.output.records import SavedPlane, StepHistory from solprop.processing import ( electric_field, field_plane, diff --git a/tests/processing/test_scan_reductions.py b/tests/processing/test_scan_reductions.py index 8c47a79c..2da602df 100644 --- a/tests/processing/test_scan_reductions.py +++ b/tests/processing/test_scan_reductions.py @@ -17,12 +17,9 @@ from solprop.exceptions import OutputError, ProcessingError from solprop.grid import FieldGrid from solprop.output.memory import MemoryOutput +from solprop.output.metadata import _example_metadata +from solprop.output.records import GridRecord, SavedPlane from solprop.output.scan import PointResult, ScanWriter -from solprop.output.schema import ( - GridRecord, - SavedPlane, - _example_metadata, -) from solprop.processing import scans from solprop.processing.axes import grid_from_record from solprop.processing.fields import get_spectral_density diff --git a/tests/recipe/test_recipe_scan.py b/tests/recipe/test_recipe_scan.py index 2996f29b..5a0b980a 100644 --- a/tests/recipe/test_recipe_scan.py +++ b/tests/recipe/test_recipe_scan.py @@ -10,7 +10,7 @@ import pytest from solprop.driver.scan import Scan, run_scan -from solprop.output.schema import RecipeRecord +from solprop.output.records import RecipeRecord from solprop.recipe import Recipe, RecipeError from solprop.recipe.document import parse_text diff --git a/tests/sessions/runs.py b/tests/sessions/runs.py index 32cebc65..5ed874cf 100644 --- a/tests/sessions/runs.py +++ b/tests/sessions/runs.py @@ -521,7 +521,7 @@ def at_plane(self, index: int) -> Any: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None Its snapshot. """ [found] = [ diff --git a/tests/sessions/test_destination_export.py b/tests/sessions/test_destination_export.py index 8c716a17..582ae105 100644 --- a/tests/sessions/test_destination_export.py +++ b/tests/sessions/test_destination_export.py @@ -41,7 +41,8 @@ from solprop.driver.simulation import metadata_for from solprop.log import NullReporter from solprop.output import CallbackOutput, HDF5Output, MemoryOutput, open_file -from solprop.output.schema import Output, SavedPlane, StoredField, stored_spectrum +from solprop.output.records import SavedPlane, StoredField +from solprop.output.store import Output, stored_spectrum from solprop.stats import StatsSpec from solprop.translate.linop import power_weight_at diff --git a/tests/sessions/test_native_session_carrier_table.py b/tests/sessions/test_native_session_carrier_table.py index a23b2a11..9cade484 100644 --- a/tests/sessions/test_native_session_carrier_table.py +++ b/tests/sessions/test_native_session_carrier_table.py @@ -49,7 +49,7 @@ from solprop.boundaries import absorber from solprop.driver import Simulation from solprop.grid import FieldGrid -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.physdata import Mixture from solprop.profiles import Linear from solprop.stepper import FixedSchedule, Multistep diff --git a/tests/sessions/test_native_session_history.py b/tests/sessions/test_native_session_history.py index 93f4f289..3ec9581a 100644 --- a/tests/sessions/test_native_session_history.py +++ b/tests/sessions/test_native_session_history.py @@ -5,7 +5,7 @@ """The native session's method history, call ledger and failures outside a march. SEP 47 sections 5.4 and 5.5: the native session owns the Adams march's state, so it -exports the method history (:class:`~solprop.output.schema.MarchSnapshot`) at every +exports the method history (:class:`~solprop.output.records.MarchSnapshot`) at every accepted endpoint a ticket names, installs a resumed run's history and call ledgers from its start, and states the ledger in its execution record. Each is held to the NumPy reference session on the same problem within the budgets of SEP 25 @@ -57,7 +57,7 @@ ResponseBudgetError, RunTerminated, ) -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.stepper.multistep import Multistep from solprop.stepper.schedule import FixedSchedule @@ -162,9 +162,9 @@ def _assert_same_snapshot(got: MarchSnapshot, expected: MarchSnapshot) -> None: Parameters ---------- - got : solprop.output.schema.MarchSnapshot + got : solprop.output.records.MarchSnapshot The snapshot under test. - expected : solprop.output.schema.MarchSnapshot + expected : solprop.output.records.MarchSnapshot The one it must reproduce. """ for name in ( @@ -198,9 +198,9 @@ def _assert_close_snapshot(got: MarchSnapshot, expected: MarchSnapshot) -> None: Parameters ---------- - got : solprop.output.schema.MarchSnapshot + got : solprop.output.records.MarchSnapshot The native snapshot. - expected : solprop.output.schema.MarchSnapshot + expected : solprop.output.records.MarchSnapshot The reference's. """ for name in ("method", "order", "rejections", "last_rejected", "collar_was_quiet"): @@ -298,7 +298,7 @@ def _resumed( The problem. run_request : solprop.translate.run_request.RunRequest Its run's request, whose planes the snapshot's position is among. - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot The history, at a save plane. ledger : dict The call ledger's record there. diff --git a/tests/sessions/test_service_loop.py b/tests/sessions/test_service_loop.py index e9e20448..cb6f231a 100644 --- a/tests/sessions/test_service_loop.py +++ b/tests/sessions/test_service_loop.py @@ -23,7 +23,8 @@ from solprop.exceptions import StopRequested from solprop.log import NullReporter from solprop.output import CallbackOutput, MemoryOutput -from solprop.output.schema import Output, SavedPlane +from solprop.output.records import SavedPlane +from solprop.output.store import Output pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning") @@ -41,7 +42,7 @@ def _run(output: Output, progress: object = "none") -> None: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output Where the planes go. progress : object, optional The reporter. diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py index 96bfc19b..20b1c852 100644 --- a/tests/test_boundaries.py +++ b/tests/test_boundaries.py @@ -32,7 +32,7 @@ from solprop.grid import Collar, EnvelopeGrid, FieldGrid, FreeGrid from solprop.linear import Frame from solprop.nonlinear.gnlse import SpecifiedMode -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import PIController, RungeKutta diff --git a/tests/test_collar_length.py b/tests/test_collar_length.py index 8f445dec..a25319d0 100644 --- a/tests/test_collar_length.py +++ b/tests/test_collar_length.py @@ -28,7 +28,7 @@ from solprop.exceptions import DriverError, GridError from solprop.grid import FieldGrid from solprop.nonlinear.tdse import TDSESampling -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.stepper import PIController from solprop.translate.masks import temporal_rates from solprop.translate.tdse import refine_response_grid diff --git a/tests/test_display.py b/tests/test_display.py index 4f6a3084..d67d42ad 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -54,7 +54,7 @@ from solprop.nonlinear.raman import SilicaTensorResponse from solprop.optics import Chain, ChirpedMirror, Material, TaylorPhase from solprop.output.hdf5 import open_file, resume -from solprop.output.schema import StepHistory +from solprop.output.records import StepHistory from solprop.physdata import Mixture, calcium_fluoride, species from solprop.physdata.mixtures import Component from solprop.processing import compression diff --git a/tests/test_far_field_statistics.py b/tests/test_far_field_statistics.py index 4ec3e1f2..a89b6ead 100644 --- a/tests/test_far_field_statistics.py +++ b/tests/test_far_field_statistics.py @@ -25,7 +25,8 @@ from solprop.exceptions import ApertureEdgeWarning, OutputError from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid, RadialGrid from solprop.modes import PlanarGrid, SlabMode -from solprop.output.schema import SavedPlane, serialise +from solprop.output.records import SavedPlane +from solprop.output.serialisation import serialise from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import ( EDGE_MOMENTS, diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 9d7329e0..15c97f2d 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -45,16 +45,11 @@ BoundaryMonitor, ) from solprop.nonlinear.responses import KerrResponse -from solprop.output import MemoryOutput, open_file, schema +from solprop.output import MemoryOutput, open_file, records from solprop.output.hdf5 import resume +from solprop.output.records import RunOutcome, TerminalCheckpoint from solprop.output.scan import read_point -from solprop.output.schema import ( - CheckpointOutput, - Output, - PlaneStore, - RunOutcome, - TerminalCheckpoint, -) +from solprop.output.store import CheckpointOutput, Output, PlaneStore from solprop.physdata import gamma3 from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium @@ -447,14 +442,14 @@ def test_a_saved_plane_validates_its_record_once( # The driver builds the record once a plane, and the store amends it with what it # has already validated -- its earlier executions -- rather than checking the whole # record again: one JSON round trip a plane, not nine. - validate = schema._outcome_record + validate = records._outcome_record validated: list[Mapping[str, object]] = [] def counted(record: Mapping[str, object]) -> dict[str, object]: validated.append(record) return validate(record) - monkeypatch.setattr(schema, "_outcome_record", counted) + monkeypatch.setattr(records, "_outcome_record", counted) output = _Recording() with warnings.catch_warnings(): warnings.simplefilter("ignore", BoundaryWarning) diff --git a/tests/test_pulses.py b/tests/test_pulses.py index e01805e2..84348029 100644 --- a/tests/test_pulses.py +++ b/tests/test_pulses.py @@ -19,7 +19,7 @@ from solprop.constants import C from solprop.exceptions import PulseError from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.pulses import ( CustomPulse, CWPulse, diff --git a/tests/test_spatial_boundaries.py b/tests/test_spatial_boundaries.py index a8c8c622..2425b85b 100644 --- a/tests/test_spatial_boundaries.py +++ b/tests/test_spatial_boundaries.py @@ -14,7 +14,7 @@ from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import DriverError, OutputError, PulseError, TranslationError from solprop.grid import EnvelopeGrid, RadialGrid -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium from solprop.stats import StatsSpec, collect, default_spec diff --git a/tests/test_spatial_statistics.py b/tests/test_spatial_statistics.py index 0c383902..32008403 100644 --- a/tests/test_spatial_statistics.py +++ b/tests/test_spatial_statistics.py @@ -20,7 +20,7 @@ from solprop.fourier import energy_omega from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid from solprop.hankel import QDHT -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import ( SpatialFlux, diff --git a/tests/test_stats.py b/tests/test_stats.py index e105687d..85d57fb4 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -18,7 +18,7 @@ from solprop.exceptions import ModeError, OutputError, SolpropWarning from solprop.grid import EnvelopeGrid, FieldGrid from solprop.modes import MarcatiliMode -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.profiles import Constant, PressureGradient from solprop.pulses import GaussianPulse from solprop.stats import ( diff --git a/tests/test_structure_figures.py b/tests/test_structure_figures.py index d4780b26..aa8a24d3 100644 --- a/tests/test_structure_figures.py +++ b/tests/test_structure_figures.py @@ -30,7 +30,8 @@ from solprop.backends.host.driver import HostDriver from solprop.backends.numpy_ref import passes from solprop.backends.protocol import Backend -from solprop.output import schema +from solprop.output.metadata import RunMetadata +from solprop.output.records import HISTORY_FIELDS from solprop.stats import CATALOGUE from solprop.stepper import schedule as schedule_module from solprop.stepper.controller import PIController @@ -207,14 +208,14 @@ def test_every_statistic_drawn_is_in_the_catalogue(name): def test_the_output_figure_names_the_history_columns_the_schema_defines(): - """The ``step_history/`` box lists exactly `schema.HISTORY_FIELDS`.""" + """The ``step_history/`` box lists exactly `HISTORY_FIELDS`.""" line = next( line for group in build.OUTPUT_GROUPS if group.name == "step_history/" for line in group.lines ) - for column in schema.HISTORY_FIELDS: + for column in HISTORY_FIELDS: assert column in line @@ -229,7 +230,7 @@ def test_the_output_figure_names_the_datasets_the_writer_creates(): def test_the_resume_key_is_the_method_the_schema_defines(): """Figure 6 names the digest by the method that computes it.""" _, method = build.RESUME_KEY.rsplit(".", 1) - assert hasattr(schema.RunMetadata, method) + assert hasattr(RunMetadata, method) def test_the_page_shows_exactly_the_figures_the_script_draws(): diff --git a/tests/test_transverse_monitor.py b/tests/test_transverse_monitor.py index 2b6e9d8b..b41a7a8b 100644 --- a/tests/test_transverse_monitor.py +++ b/tests/test_transverse_monitor.py @@ -30,7 +30,7 @@ from solprop.modes import PlanarGrid, SlabMode from solprop.monitor import BoundaryCheck from solprop.output.hdf5 import resume -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.translate.transverse import _nodes_window from solprop.transverse_monitor import BACKSTOP_READS, TransverseEdge, _row_energy from test_transverse_resolution import LENGTH, suite diff --git a/tests/test_transverse_nonlinear.py b/tests/test_transverse_nonlinear.py index e5a33a71..1b8387b5 100644 --- a/tests/test_transverse_nonlinear.py +++ b/tests/test_transverse_nonlinear.py @@ -26,7 +26,7 @@ from solprop.exceptions import BoundaryError, BoundaryWarning from solprop.grid import FieldGrid, RadialGrid from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.physdata import gamma3 from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium, critical_power diff --git a/tests/test_transverse_reads.py b/tests/test_transverse_reads.py index 30ed9b0a..359fdf35 100644 --- a/tests/test_transverse_reads.py +++ b/tests/test_transverse_reads.py @@ -40,7 +40,7 @@ from solprop.grid import EnvelopeGrid, LineGrid from solprop.linear import Frame from solprop.monitor import BoundaryCheck -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, Multistep, PIController, RungeKutta diff --git a/tests/test_transverse_witnesses.py b/tests/test_transverse_witnesses.py index e5e19c94..101c4ecf 100644 --- a/tests/test_transverse_witnesses.py +++ b/tests/test_transverse_witnesses.py @@ -14,7 +14,7 @@ from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import BoundaryWarning, TranslationError from solprop.grid import EnvelopeGrid, LineGrid -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, PIController, RungeKutta diff --git a/tests/test_vector_raman_oracles.py b/tests/test_vector_raman_oracles.py index cfcc75ce..1bda7c81 100644 --- a/tests/test_vector_raman_oracles.py +++ b/tests/test_vector_raman_oracles.py @@ -27,7 +27,7 @@ from solprop import fourier from solprop.constants import EPS0 from solprop.nonlinear.raman import MolecularOptions, RamanResponse -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import MolecularRaman from vector_raman_declaration import Declaration, Term from vector_raman_tensor_oracle import ( diff --git a/tests/test_weak_signal.py b/tests/test_weak_signal.py index 36336104..723b123e 100644 --- a/tests/test_weak_signal.py +++ b/tests/test_weak_signal.py @@ -43,7 +43,7 @@ from solprop.exceptions import BoundaryError, BoundaryWarning from solprop.grid import EnvelopeGrid, LineGrid from solprop.monitor import BoundaryCheck -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.physdata.glasses import fused_silica from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium diff --git a/tests/translate/test_operator_settings.py b/tests/translate/test_operator_settings.py index 667d5cb6..d70cf42e 100644 --- a/tests/translate/test_operator_settings.py +++ b/tests/translate/test_operator_settings.py @@ -27,7 +27,7 @@ from solprop.grid import EnvelopeGrid, LineGrid, RadialGrid from solprop.linear import Frame from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.profiles import Linear from solprop.pulses import GaussianPulse from solprop.spatial import ( diff --git a/tests/translate/test_run_request.py b/tests/translate/test_run_request.py index c331e816..16688ffe 100644 --- a/tests/translate/test_run_request.py +++ b/tests/translate/test_run_request.py @@ -18,7 +18,7 @@ from sessions.executors import soliton from solprop.exceptions import TranslationError -from solprop.output.schema import FieldRetention +from solprop.output.store import FieldRetention from solprop.stats import StatsSpec from solprop.stepper import Multistep from solprop.translate.problem import StepperSettings diff --git a/tests/translate/test_tdse_contract.py b/tests/translate/test_tdse_contract.py index 1dd1db4d..f098fe13 100644 --- a/tests/translate/test_tdse_contract.py +++ b/tests/translate/test_tdse_contract.py @@ -27,7 +27,7 @@ TDSEGasResponse, TDSESampling, ) -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.translate.tdse import ( AtomicCallBudget, AtomicCallLedger, diff --git a/tests/vector_raman_declaration.py b/tests/vector_raman_declaration.py index fd3751ad..8c8eea4e 100644 --- a/tests/vector_raman_declaration.py +++ b/tests/vector_raman_declaration.py @@ -6,7 +6,7 @@ from dataclasses import dataclass -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise @dataclass(frozen=True) diff --git a/tools/size_budget.toml b/tools/size_budget.toml index abd248a1..542c4399 100644 --- a/tools/size_budget.toml +++ b/tools/size_budget.toml @@ -22,7 +22,6 @@ max_lines = 4000 # Remove an entry once its module is split below max_lines. "src/solprop/driver/simulation.py" = 10550 "src/solprop/translate/problem.py" = 5444 -"src/solprop/output/schema.py" = 5073 [private_imports] # Private names imported from another package (the root's shared private modules, such From f65c32875ce5a0fb5fd0eaef76c5eb0b18ccb315 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:52:21 +0000 Subject: [PATCH 04/42] Take every stored identity through one canonical digest solprop/_identity.py holds canonical_json, text_digest and canonical_digest; the resume key, scan records, coupling bases, TDSE bases and descriptors, material populations and the TDSE consistency cache use them. The digests keep their values, pinned by golden tests; a value JSON cannot hold now raises instead of being recorded by str. Co-Authored-By: Claude Opus 5.5 --- docs/dev/architecture.md | 4 +- src/solprop/_identity.py | 45 ++++++++++++ src/solprop/coupling/plane.py | 17 +---- src/solprop/coupling/port.py | 6 +- src/solprop/driver/batch.py | 4 +- src/solprop/driver/material_output.py | 16 +---- src/solprop/driver/tdse_consistency.py | 5 +- src/solprop/nonlinear/tdse.py | 16 +---- src/solprop/output/metadata.py | 16 ++--- src/solprop/output/scan.py | 8 +-- src/solprop/output/serialisation.py | 9 +-- src/solprop/physdata/selection.py | 12 +--- src/solprop/processing/scans.py | 5 +- src/solprop/translate/envelope_plasma.py | 3 +- src/solprop/translate/tdse.py | 10 ++- tests/test_identity.py | 90 ++++++++++++++++++++++++ 16 files changed, 175 insertions(+), 91 deletions(-) create mode 100644 src/solprop/_identity.py create mode 100644 tests/test_identity.py diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 1d0daf86..02f0c565 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -242,7 +242,9 @@ directly, without a contract, a backend or an output handler, which is what make cross-check of the reference backend. `processing/windows.py` is a candidate module, drawn dashed in figure 1, to be written only for an agreed use case. `_display.py` is private: the formatter behind every class's printed block, imported by -any layer and importing none. +any layer and importing none. So is `_identity.py`: the canonical JSON text of a record +and its SHA-256, which every stored identity is (a run's resume key, a scan's common +record, a coupling plane's basis, a TDSE basis or descriptor, a material population). The layout does not mirror Luna's module tree: what `NonlinearRHS.jl` does spans response normalisation, translation and backend passes; `Maths.jl` mostly becomes SciPy diff --git a/src/solprop/_identity.py b/src/solprop/_identity.py new file mode 100644 index 00000000..0f3c38c6 --- /dev/null +++ b/src/solprop/_identity.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The canonical identity of a record: its one JSON text, and the SHA-256 of that text. + +Every digest that identifies a record -- a run's resume key, a scan's common record, a +coupling plane's basis, a material population, a TDSE basis or descriptor, a cache key +-- is taken over :func:`canonical_json`, so that two records equal as JSON have one +identity on every platform and in every process. The text has its keys sorted and no +whitespace. A value JSON cannot hold raises :class:`TypeError`: an identity that +stringified an object would silently merge records that differ (an array's ``str`` is +abbreviated). Non-finite numbers are written as JSON's ``NaN`` and ``Infinity``, which +spell them deterministically (a material's open interval bound is infinite), unless the +caller asks for finite values only. + +Like ``_display``, this module imports nothing from the rest of the package, so any +layer may use it. +""" + +import hashlib +import json + +__all__ = ["canonical_digest", "canonical_json", "text_digest"] + + +def canonical_json(value: object, *, finite: bool = False) -> str: + """Return the one JSON text of `value` an identity or an ordering is taken over. + + ``finite=True`` refuses NaN and infinity with :class:`ValueError`, for a record + whose every number is a measured or configured finite quantity. + """ + return json.dumps( + value, sort_keys=True, separators=(",", ":"), allow_nan=not finite + ) + + +def text_digest(text: str) -> str: + """Return the SHA-256 of a text's UTF-8 bytes: 64 lowercase hex digits.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def canonical_digest(value: object, *, finite: bool = False) -> str: + """Return the identity of a JSON-ready record: the digest of its canonical JSON.""" + return text_digest(canonical_json(value, finite=finite)) diff --git a/src/solprop/coupling/plane.py b/src/solprop/coupling/plane.py index ee51db23..eb8c78ef 100644 --- a/src/solprop/coupling/plane.py +++ b/src/solprop/coupling/plane.py @@ -41,8 +41,6 @@ """ import dataclasses -import hashlib -import json import math from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field @@ -52,6 +50,7 @@ from jaxtyping import Complex, Float from solprop import fourier +from solprop._identity import canonical_digest from solprop._process import host_blas from solprop.adiabatic import AdiabaticAdmission from solprop.constants import EPS0, C @@ -127,15 +126,6 @@ def _frozen( return array -def _canonical(record: object) -> str: - """Return the canonical JSON text an identity is a digest of. - - A material's open interval bound is infinite, and JSON's ``Infinity`` spells it - deterministically. - """ - return json.dumps(record, sort_keys=True, separators=(",", ":")) - - def basis_identity(route: PlaneKind, record: Mapping[str, object]) -> str: """Return the identity of a plane's basis: a digest of its route and record. @@ -164,8 +154,7 @@ def basis_identity(route: PlaneKind, record: Mapping[str, object]) -> str: >>> basis_identity("radial", record) == basis_identity("radial", dict(record)) True """ - text = _canonical({"route": route, "record": dict(record)}) - return hashlib.sha256(text.encode("utf-8")).hexdigest() + return canonical_digest({"route": route, "record": dict(record)}) _QUANTUM_DIGITS = 12 @@ -301,7 +290,7 @@ def lattice_identity(grid: TimeGrid) -> str: "dt_fine": float(grid.dt_fine), "reference_wavelength": float(grid.reference_wavelength), } - return hashlib.sha256(_canonical(record).encode("utf-8")).hexdigest() + return canonical_digest(record) @dataclass(frozen=True) diff --git a/src/solprop/coupling/port.py b/src/solprop/coupling/port.py index 22d3b42b..72f44b59 100644 --- a/src/solprop/coupling/port.py +++ b/src/solprop/coupling/port.py @@ -18,8 +18,6 @@ whatever the array shapes. :func:`solprop.coupling.coupler.couple` computes one. """ -import hashlib -import json import math from collections.abc import Mapping from dataclasses import dataclass @@ -30,6 +28,7 @@ from jaxtyping import Complex, Float from solprop import fourier +from solprop._identity import canonical_digest from solprop.coupling.models import ScalarModeBasis from solprop.coupling.plane import ( PlaneBasis, @@ -226,8 +225,7 @@ def identity(self) -> str: # numpydoc ignore=RT01 "z": self.geometry.z, "side": self.geometry.side, } - text = json.dumps(record, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(text.encode("utf-8")).hexdigest() + return canonical_digest(record) def accepts(self, other: "InputPort") -> str | None: """Return why a launch bound to `other` cannot enter this port, or None. diff --git a/src/solprop/driver/batch.py b/src/solprop/driver/batch.py index a19b3bd9..da8a449f 100644 --- a/src/solprop/driver/batch.py +++ b/src/solprop/driver/batch.py @@ -5,12 +5,12 @@ """Prepare compatible fixed-schedule scan members without changing their backend.""" import dataclasses -import hashlib from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass import numpy as np +from solprop._identity import text_digest from solprop.backends.node_chunk import plan_node_chunk from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError @@ -91,7 +91,7 @@ def _signature(member: BatchMember) -> str: # BLAS does not split a batch that did not change. "modal_identity": _modal_identity(problem), } - return hashlib.sha256(_json(descriptor).encode()).hexdigest() + return text_digest(_json(descriptor)) def _modal_identity(problem: PropagationProblem) -> Mapping[str, object] | None: diff --git a/src/solprop/driver/material_output.py b/src/solprop/driver/material_output.py index 7a579e74..047d7ff0 100644 --- a/src/solprop/driver/material_output.py +++ b/src/solprop/driver/material_output.py @@ -16,8 +16,6 @@ import dataclasses import enum import functools -import hashlib -import json import math import types from collections.abc import Iterator, Mapping, Sequence @@ -26,6 +24,7 @@ import numpy as np from jaxtyping import Float +from solprop._identity import canonical_digest, canonical_json from solprop.constants import EPS0, C from solprop.grid import TimeGrid from solprop.modes.fill import GasFillMixin @@ -537,11 +536,7 @@ def add(prefix: str, mixture: Mixture) -> None: for component in record: identities.setdefault( f"{prefix}{component['label']}", - hashlib.sha256( - _canonical_entry( - cast("Mapping[str, object]", component["density"]) - ).encode() - ).hexdigest(), + canonical_digest(component["density"]), ) for value in found: @@ -625,15 +620,10 @@ def _rate_policies(found: Sequence[object]) -> list[dict[str, object]]: entries = [ value.manifest_entry() for value in found if isinstance(value, OptmatRate) ] - distinct = {_canonical_entry(entry): entry for entry in entries} + distinct = {canonical_json(entry): entry for entry in entries} return [distinct[key] for key in sorted(distinct)] -def _canonical_entry(entry: Mapping[str, object]) -> str: - """Return an entry's canonical JSON, for ordering and deduplication.""" - return json.dumps(entry, sort_keys=True, separators=(",", ":"), default=str) - - def material_manifest( roots: Sequence[object], *, diff --git a/src/solprop/driver/tdse_consistency.py b/src/solprop/driver/tdse_consistency.py index cc4433b1..577f227a 100644 --- a/src/solprop/driver/tdse_consistency.py +++ b/src/solprop/driver/tdse_consistency.py @@ -23,7 +23,6 @@ imaginary parts are compared together, never the real part alone. """ -import hashlib import json import logging import math @@ -42,6 +41,7 @@ import solprop import solprop.driver.tdse as tdse_runtime +from solprop._identity import canonical_digest from solprop.constants import EPS0, C from solprop.exceptions import ( BackendError, @@ -356,8 +356,7 @@ def cache_directory() -> Path: def _key(inputs: Mapping[str, object]) -> str: """Digest the cache key's canonical inputs.""" - encoded = json.dumps(inputs, sort_keys=True, separators=(",", ":"), allow_nan=False) - return hashlib.sha256(encoded.encode()).hexdigest() + return canonical_digest(inputs, finite=True) def _reduction_from(record: object) -> LinearReduction | None: diff --git a/src/solprop/nonlinear/tdse.py b/src/solprop/nonlinear/tdse.py index fd2bfa33..379faea3 100644 --- a/src/solprop/nonlinear/tdse.py +++ b/src/solprop/nonlinear/tdse.py @@ -12,14 +12,13 @@ (:class:`TDSEConsistency`). """ -import hashlib -import json import math import re from collections.abc import Sequence from dataclasses import KW_ONLY, asdict, dataclass, field from typing import Literal +from solprop._identity import canonical_digest from solprop.constants import AU_TIME from solprop.exceptions import MaterialError from solprop.nonlinear.responses import ResponseTraits @@ -213,10 +212,7 @@ def identity(self) -> str: # numpydoc ignore=RT01 Hexadecimal digest of canonical JSON, including the source record. """ - encoded = json.dumps( - asdict(self), sort_keys=True, separators=(",", ":"), allow_nan=False - ) - return hashlib.sha256(encoded.encode()).hexdigest() + return canonical_digest(asdict(self), finite=True) def _radial_axis(edges: Sequence[float], orders: Sequence[int]) -> None: @@ -416,13 +412,7 @@ def identity(self) -> str: # numpydoc ignore=RT01 Hexadecimal digest of canonical JSON with ``kind="hacc_helium"``. """ - encoded = json.dumps( - {"kind": "hacc_helium", **asdict(self)}, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - return hashlib.sha256(encoded.encode()).hexdigest() + return canonical_digest({"kind": "hacc_helium", **asdict(self)}, finite=True) @property def label(self) -> str: # numpydoc ignore=RT01 diff --git a/src/solprop/output/metadata.py b/src/solprop/output/metadata.py index f3022160..e18d81ea 100644 --- a/src/solprop/output/metadata.py +++ b/src/solprop/output/metadata.py @@ -26,7 +26,6 @@ import dataclasses import functools -import hashlib import json import logging import re @@ -38,6 +37,7 @@ import numpy as np from solprop._display import Displayed, engineering, line +from solprop._identity import canonical_digest, canonical_json, text_digest from solprop.exceptions import MaterialError, OutputError from solprop.grid import EnvelopeGrid from solprop.modes.material import JsonValue @@ -57,7 +57,6 @@ from solprop.output.serialisation import ( _RECORD_ARRAY, _ArrayReference, - _canonical_json, _rebuild, _resolved, _stored_entry, @@ -159,7 +158,7 @@ def _differences( for index, (one, other) in enumerate(zip(left, right, strict=True)): _differences(one, other, f"{path}[{index}]", found) return - if _canonical_json(left) != _canonical_json(right): + if canonical_json(left) != canonical_json(right): found.append(path) @@ -287,11 +286,7 @@ def _tables_text(tables: object) -> str: if not isinstance(tables, Mapping): return "none" model = tables.get("model_id") - digest = ( - hashlib.sha256(str(model).encode("utf-8")).hexdigest()[:12] - if model is not None - else "unnamed" - ) + digest = text_digest(str(model))[:12] if model is not None else "unnamed" # The digest first: the row is truncated to the block's width, and it is the # entry two files are compared by. return ( @@ -792,8 +787,7 @@ def configuration_hash(self) -> str: @functools.cached_property def _configuration_digest(self) -> str: """The resume key, computed once per record (:meth:`configuration_hash`).""" - text = _canonical_json(self._hashed_record) - return hashlib.sha256(text.encode("utf-8")).hexdigest() + return canonical_digest(self._hashed_record) def with_backend(self, backend: BackendRecord) -> "RunMetadata": """Return this record with a run's execution facts, its resume key kept. @@ -999,7 +993,7 @@ def to_json(self) -> str: >>> sorted(json.loads(_example_metadata().to_json()))[:3] ['backend', 'configuration', 'created'] """ - return _canonical_json(self._document) + return canonical_json(self._document) @functools.cached_property def _document(self) -> dict[str, JsonValue]: diff --git a/src/solprop/output/scan.py b/src/solprop/output/scan.py index 278930f9..ead5e7be 100644 --- a/src/solprop/output/scan.py +++ b/src/solprop/output/scan.py @@ -8,7 +8,6 @@ layout arrays and exact CSR statistics are rebuilt from committed groups at close. """ -import hashlib import json import logging from collections.abc import Mapping @@ -20,6 +19,7 @@ import h5py import numpy as np +from solprop._identity import canonical_json, text_digest from solprop.driver._scan_model import Scan from solprop.exceptions import OutputError, ProcessingError from solprop.output._scan_lock import read_scan, write_scan @@ -40,12 +40,12 @@ def _json(value: object) -> str: array keeps its values here, whatever its size. A run record's arrays go beside its text instead, through :attr:`PointResult.metadata_arrays`. """ - return json.dumps(_inlined(serialise(value)), sort_keys=True, separators=(",", ":")) + return canonical_json(_inlined(serialise(value))) def _hash(text: str) -> str: """Identify a serialised common record by its SHA-256 digest.""" - return hashlib.sha256(text.encode()).hexdigest() + return text_digest(text) _RECORD_ARRAYS = "record_arrays" @@ -741,7 +741,7 @@ def _refuse_other_schemas(file: h5py.File) -> None: ) # Records are content-addressed, so every point of one release shares the # same reference and the common record is read once. - key = json.dumps(declared, sort_keys=True) + key = canonical_json(declared) if key not in accepted: _refuse_point_schema(file, int(name), document) accepted.add(key) diff --git a/src/solprop/output/serialisation.py b/src/solprop/output/serialisation.py index 1e38e3c8..c7e58142 100644 --- a/src/solprop/output/serialisation.py +++ b/src/solprop/output/serialisation.py @@ -17,7 +17,6 @@ import dataclasses import hashlib -import json import logging from collections.abc import Mapping, Sequence from dataclasses import dataclass @@ -27,6 +26,7 @@ import numpy as np from scipy.interpolate import CubicSpline +from solprop._identity import canonical_json from solprop.exceptions import OutputError from solprop.modes.material import JsonValue @@ -213,7 +213,7 @@ def _serialise_sequence(value: object) -> list[JsonValue]: # a set or a sequence. members = [serialise(item) for item in value] # type: ignore[union-attr] - checked by the caller if isinstance(value, set | frozenset): - return sorted(members, key=_canonical_json) + return sorted(members, key=canonical_json) return members @@ -478,11 +478,6 @@ def _recorded_inline(values: np.ndarray) -> bool: return values.size <= INLINE_ARRAY_LIMIT -def _canonical_json(value: JsonValue) -> str: - """Return the one textual form of a structure that a digest may be taken over.""" - return json.dumps(value, sort_keys=True, separators=(",", ":")) - - def _decode_array(record: Mapping[str, object]) -> np.ndarray: """Return the array a ``__ndarray__`` record describes. diff --git a/src/solprop/physdata/selection.py b/src/solprop/physdata/selection.py index ef688a6c..307d40e7 100644 --- a/src/solprop/physdata/selection.py +++ b/src/solprop/physdata/selection.py @@ -26,12 +26,12 @@ raise. """ -import json import math from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field from typing import Final, Protocol, cast, runtime_checkable +from solprop._identity import canonical_json from solprop.exceptions import MaterialError __all__ = [ @@ -533,14 +533,6 @@ def to_dict(self) -> Mapping[str, object]: } -def _canonical(value: object) -> str: - """Return plain values as canonical JSON, for ordering and deduplication. - - - ``value``: plain values: mappings, sequences, strings, numbers, None. - """ - return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) - - def _plain(value: object) -> object: """Return a value with tuples as lists and mappings as dicts, recursively.""" if isinstance(value, Mapping): @@ -553,7 +545,7 @@ def _plain(value: object) -> object: def _entries(values: Iterable[Mapping[str, object]]) -> tuple[dict[str, object], ...]: """Return entries as plain dicts, each once, sorted by their canonical JSON.""" distinct = { - _canonical(_plain(entry)): cast("dict[str, object]", _plain(entry)) + canonical_json(_plain(entry)): cast("dict[str, object]", _plain(entry)) for entry in values } return tuple(distinct[key] for key in sorted(distinct)) diff --git a/src/solprop/processing/scans.py b/src/solprop/processing/scans.py index be944c9c..27410474 100644 --- a/src/solprop/processing/scans.py +++ b/src/solprop/processing/scans.py @@ -22,6 +22,7 @@ from jaxtyping import Float from solprop._display import Displayed, line, tail +from solprop._identity import canonical_json, text_digest from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.output._scan_lock import read_scan @@ -542,7 +543,7 @@ def _provenance( f"{getattr(function, '__module__', '')}." f"{getattr(function, '__qualname__', '')}" ), - "source_hash": hashlib.sha256(source.encode()).hexdigest() if source else None, + "source_hash": text_digest(source) if source else None, "source_available": bool(source), "units": units, "dtype": str(reduction.dtype), @@ -771,7 +772,7 @@ def spectrum_wavelength(self, index: int | None = None) -> np.ndarray: ) low, high = parameters["bounds"] bounds = (float(low), float(high)) - fingerprint = json.dumps([grid, bounds], sort_keys=True) + fingerprint = canonical_json([grid, bounds]) records[fingerprint] = (_rebuild(GridRecord, grid), bounds) if not records: where = "" if index is None else f" at point {index}" diff --git a/src/solprop/translate/envelope_plasma.py b/src/solprop/translate/envelope_plasma.py index 9c0d2bc0..3fb91f40 100644 --- a/src/solprop/translate/envelope_plasma.py +++ b/src/solprop/translate/envelope_plasma.py @@ -19,6 +19,7 @@ from jaxtyping import Bool, Complex, Float from scipy import special +from solprop._identity import text_digest from solprop.constants import CHARGE_MASS_RATIO from solprop.exceptions import ConvergenceError, SolpropWarning, TranslationError from solprop.grid import EnvelopeGrid @@ -621,7 +622,7 @@ def build_cycle_integrals( _FORMAT_VERSION, ) ) - identity = hashlib.sha256(key.encode()).hexdigest() + identity = text_digest(key) if identity in _CYCLES: cached = _CYCLES[identity] # A material update borrows its existing immutable table. Equivalent diff --git a/src/solprop/translate/tdse.py b/src/solprop/translate/tdse.py index 4d33f78e..26894add 100644 --- a/src/solprop/translate/tdse.py +++ b/src/solprop/translate/tdse.py @@ -20,6 +20,7 @@ import numpy as np from jaxtyping import Bool, Float +from solprop._identity import canonical_json, text_digest from solprop.exceptions import ( BackendError, ResponseBudgetError, @@ -482,8 +483,7 @@ class TDSEDescriptor: def __post_init__(self) -> None: """Protect the seam identity against independently modified scalar fields.""" - digest = hashlib.sha256(self.configuration_json.encode()).hexdigest() - if digest != self.identity: + if text_digest(self.configuration_json) != self.identity: raise TranslationError( "TDSE descriptor identity does not match its configuration" ) @@ -710,12 +710,10 @@ def from_response( "shadow_order": order, "outputs": outputs, } - encoded = json.dumps( - specification, sort_keys=True, separators=(",", ":"), allow_nan=False - ) + encoded = canonical_json(specification, finite=True) return cls( configuration_json=encoded, - identity=hashlib.sha256(encoded.encode()).hexdigest(), + identity=text_digest(encoded), kerr_model=response.kerr_model, remainder_scale=response.remainder_scale, cubic_scale=response.cubic_scale, diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 00000000..5a51047e --- /dev/null +++ b/tests/test_identity.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Golden digests: the identities stamped in files keep their values. + +A resume, a scan's common record, a coupling plane's basis and the TDSE caches compare +these digests with the ones files already hold, so a change of the canonical text +changes what an old file is accepted against. Each value below was computed before the +digests were brought onto one helper; a change to any of them is a deliberate break of +stored identities and needs a release note. +""" + +import math + +import numpy as np +import pytest + +from solprop._identity import canonical_digest, canonical_json, text_digest +from solprop.coupling.plane import basis_identity, lattice_identity, space_record +from solprop.driver import tdse_consistency +from solprop.grid import EnvelopeGrid, RadialGrid +from solprop.nonlinear.tdse import TDSEBasis +from solprop.output.metadata import _example_metadata +from solprop.output.scan import _hash, _json + + +def test_the_canonical_text_sorts_keys_and_drops_whitespace(): + record = {"b": [1, 2.5, None], "a": {"z": True, "y": "text"}, "c": math.inf} + assert canonical_json(record) == ( + '{"a":{"y":"text","z":true},"b":[1,2.5,null],"c":Infinity}' + ) + assert canonical_digest(record) == text_digest(canonical_json(record)) + assert text_digest("") == ( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + + +def test_a_finite_identity_refuses_a_non_finite_number(): + with pytest.raises(ValueError, match="Out of range float"): + canonical_digest({"intensity": math.nan}, finite=True) + + +def test_an_identity_refuses_a_value_json_cannot_hold(): + # Never a str() of it: an array's text is abbreviated, and two would merge. + with pytest.raises(TypeError, match="not JSON serializable"): + canonical_digest({"values": np.zeros(3)}) + + +@pytest.mark.parametrize( + ("name", "digest"), + [ + ( + "metadata", + "9e1f945c0ab5246cc011e9bb713242ac19d8fb4e76b58ba4e95f23af58fc6964", + ), + ("scan", "80f9c7ee2c67bb3246215ab42ce1d49fa0cb9f93e774cd9b2d543229b4ae335b"), + ("basis", "d7b11decb56fec6a504280a7c9e26f83c5f33d84fa111aa9a9b27f9e87481587"), + ( + "lattice", + "1b956475d651c1aedd23cc23d6d6c51ab37b58c7173ff491c9d16c1772c1bc7b", + ), + ( + "tdse_basis", + "f8e756518be0c594c63712a70b5d0d969ce2d7b075828c3b7a5ebf2fe35dd0af", + ), + ( + "consistency", + "a8bf3655b53dc839e2cebdf17c4c950a0061eeef6e7e0e3a38d75858904949a1", + ), + ], +) +def test_a_stored_identity_keeps_its_value(name, digest): + computed = { + "metadata": lambda: _example_metadata().configuration_hash(), + "scan": lambda: _hash(_json({"gamma": 0.11, "modes": [1, 2], "inf": math.inf})), + "basis": lambda: basis_identity("radial", space_record(RadialGrid(1e-3, 64))), + "lattice": lambda: lattice_identity( + EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + ), + "tdse_basis": lambda: ( + TDSEBasis( + "manufactured", (0.0, 1e-9), (8,), 3, 1e-16, source="golden" + ).identity + ), + "consistency": lambda: tdse_consistency._key( + {"species": "helium", "intensity": 1e18, "order": [1, 2]} + ), + }[name]() + assert computed == digest From 5a325b6f0a8ed5a4b82cac950f68718044e05e1c Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:52:52 +0000 Subject: [PATCH 05/42] Split solprop.tools into a package of seven modules A pure move: the function and class bodies are unchanged; solprop.tools re-exports every public name. Co-Authored-By: Claude Opus 5.5 --- docs/dev/architecture.md | 2 +- docs/dev/figures/build.py | 13 +- docs/dev/figures/layers.svg | 392 +-- src/solprop/recipe/pins.py | 5 +- src/solprop/recipe/preview.py | 4 +- src/solprop/tools.py | 3600 --------------------------- src/solprop/tools/__init__.py | 128 + src/solprop/tools/beam.py | 299 +++ src/solprop/tools/capillary.py | 843 +++++++ src/solprop/tools/design.py | 1297 ++++++++++ src/solprop/tools/phase_matching.py | 466 ++++ src/solprop/tools/pulse.py | 148 ++ src/solprop/tools/soliton.py | 452 ++++ src/solprop/tools/waveguide.py | 284 +++ tests/recipe/test_pins.py | 2 +- tests/recipe/test_preview.py | 4 +- tests/test_design_solvers.py | 27 +- tests/test_soliton_content.py | 28 +- tests/test_tools.py | 41 +- 19 files changed, 4193 insertions(+), 3842 deletions(-) delete mode 100644 src/solprop/tools.py create mode 100644 src/solprop/tools/__init__.py create mode 100644 src/solprop/tools/beam.py create mode 100644 src/solprop/tools/capillary.py create mode 100644 src/solprop/tools/design.py create mode 100644 src/solprop/tools/phase_matching.py create mode 100644 src/solprop/tools/pulse.py create mode 100644 src/solprop/tools/soliton.py create mode 100644 src/solprop/tools/waveguide.py diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index c480060a..30726919 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -208,7 +208,7 @@ against `src/solprop` by `tests/test_structure_figures.py`. |---|---|---| | `__init__.py`, `exceptions.py`, `constants.py` | Lazy public imports, package logging, exception hierarchy and SI constants | package / P | | `grid.py`, `fourier.py`, `hankel.py`, `spatial.py`, `adiabatic.py` | Temporal and spatial grids (radial, the Cartesian plane, and `LineGrid`, its x axis uniform in y per unit width), Fourier conventions, raw Hankel transforms, spatial media, the axial-flux measure of a plane (`SpatialFlux`, SEP 34) and the adiabatic path certificate of a z-varying medium | P | -| `profiles.py`, `gasflow.py`, `pulses.py`, `noise.py`, `polarisation.py`, `optics.py`, `tools.py` | Longitudinal paths, steady gas flow in a hollow fibre (the taper- and temperature-aware, choked, regime-checked fill, its flow report and calculators), temporal/spatial inputs, noise, polarisation algebra, linear optical elements as transfer functions, and the soliton and phase-matching design calculators | P | +| `profiles.py`, `gasflow.py`, `pulses.py`, `noise.py`, `polarisation.py`, `optics.py`, `tools/` | Longitudinal paths, steady gas flow in a hollow fibre (the taper- and temperature-aware, choked, regime-checked fill, its flow report and calculators), temporal/spatial inputs, noise, polarisation algebra, linear optical elements as transfer functions, and the design calculators: pulse shape numbers (`tools/pulse.py`), soliton numbers (`soliton.py`), beam numbers (`beam.py`), the fill and zero-dispersion search a design evaluates (`waveguide.py`), dispersive-wave phase matching (`phase_matching.py`), the parameter solvers (`design.py`) and the soliton parameters and designs of a capillary (`capillary.py`), all re-exported from `solprop.tools` | P | | `coupling/` | Coupling simulations through a coherent field at a reference plane (SEP 31): the field plane with its reference, geometry, metric, basis identity and provenance (`plane.py`), the input port of a receiving simulation and its bound launch (`port.py`), temporal remapping (`remap.py`), the coupling models, their scalar mode profiles and the recorded pair envelopes (`models.py`, `envelopes.py`), `couple` with its admission budget and report (`coupler.py`), and the synthesis of a guide's exit field (`outcouple.py`), and the transport core: the linear elements between planes (`elements.py`) and the ordered transport of a plane with its ledger (`transport.py`); NumPy, SciPy and other P modules only | P | | `physdata/` | Species, mixtures, equations of state, transport properties (viscosity, sound speed, mean free path), optical material data, metals, nonlinear and Raman data | P | | `modes/` | Mode protocols and derived quantities; capillary, antiresonant, tabulated, step-index, rectangular and planar (slab, uniform in y, per unit width) modes, with `PlanarGrid` for the planar guide resolved in y; shared gas fill, quadrature and collections; full-vector modal records, material laws and regions, bound step-index and radial graded-index providers, and the guide that pairs a provider with a mode collection, and the connection of a basis that moves along z | P | diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 216dead8..bd5f82db 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -597,7 +597,18 @@ class Layer(NamedTuple): Module("transverse_monitor.py"), Module("polarisation.py"), Module("optics.py"), - Module("tools.py"), + Module( + "tools/", + ( + "pulse", + "soliton", + "beam", + "waveguide", + "phase_matching", + "design", + "capillary", + ), + ), Module( "coupling/", ( diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index c722e22a..82544399 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -1,6 +1,6 @@ + width="1200" height="1824.0" viewBox="0 0 1200 1824.0"> - + The layers of solprop, and every module in each Bands and colours from architecture.md §3 and decisions.md ADR-001; modules from src/solprop. Arrows point from the layer that imports to the layer it imports. @@ -84,13 +84,13 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: X unwritten candidate, not scheduled (architecture.md §4) - - -P -physics / convenience -may import -numpy, scipy, stdlib -other P modules + + +P +physics / convenience +may import +numpy, scipy, stdlib +other P modules constants.py @@ -123,191 +123,193 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: transverse_monitor.py polarisation.py - + optics.py - -tools.py - -coupling/ -plane · port · remap · models -coupler · outcouple · envelopes -transport · elements - -physdata/ -sellmeier · equation_of_state -gases · glasses · metals -nonlinear_data · raman_types -species · mixtures · transport -selection - -modes/ -protocol · base · capillary -antiresonant · tabulated · fill -stepindex · material · vector -guide · graded_index · connection -rectangular · slab · collection -quadrature - -nonlinear/ -responses · ionisation -optmat_rates · envelope_plasma -raman · gnlse · normalisation -mixtures · optmat_raman · tdse - - -T -translation -may import -P, numpy, scipy - -translate/ -problem · routes · stage · linop -expansion · chebyshev · modal -modal_tables · modal_path · coupled -general · radial · free · planar -spatial · transverse · collar_check -flux · coupling · responses -ionisation · plasma_integration -plasma_source · envelope_plasma -raman · masks · tdse · tdse_source -run_request - - -B -backends -may import -numpy; the native package in native/ - -backends/ -protocol · registry · spatial -populations · memory · node_chunk -multistep · adams_core · rk_core -exponential · low_storage · session -events · export · session_core -sessions - -backends/host/ -driver · adams · general -low_storage · group · session -group_session - -backends/numpy_ref/ -backend · batch · passes -admission · envelope_plasma -general_modal · tdse - - -D -driver -may import -P, T -B through the seam, h5py - -log.py - -stats.py - -stepper/ -tableau · runge_kutta · multistep -low_storage · controller · schedule - -output/ -schema · capability · memory -callback · hdf5 · tdse_archive -snapshots · scan · scan_artifacts -resumable - -scan.py - -calibration/ -command · controller · first_use -child · grain · problems · refine -selection - -driver/executors/ -local · pool · queue · slurm -batch - -driver/ -simulation · run · summary -interface · scaffold · mixtures -material_output · tdse -tdse_consistency · scan · batch -defaults · estimate · plane_view -service - -recipe/ -core · document · errors · export -metadata · paths · pins · preview -provenance · quantity · registry - -cli.py - - -X -processing / plotting -may import -numpy, scipy -matplotlib, D (outputs) -T (table identities) - -processing/ -beams · axes · fields · spectra -time · coherence · compression -mixtures · modal · planes -plane_file · scans - -plotting/ -propagation · statistics -spectrograms · style · geometry - -sol.py - -capabilities.py - -service/ -agent · beam · beams · client -design · explore · figures · frames -handlers · install · job · kernel -launch · machines · messages -postprocess · recipes · record -reduce · results · run · runs -scan_reductions · sheet · states -waveguide · worker - -processing/ -windows - - -T → P physics objects - - -B → T delta_phi, prefactor_at, the stage bundle - - -D → B backends.select, then the seam - - -X → D a saved run, through the Output protocol - - - -1 - - - -2 - - - -3 - - - -4 -1 Simulation calls translate.assemble -2 the driver builds the grid, the modes, the pulse and the responses -3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object -4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X -The package-wide modules carry no layer letter: __init__.py · exceptions.py + +tools/ +pulse · soliton · beam · waveguide +phase_matching · design · capillary + +coupling/ +plane · port · remap · models +coupler · outcouple · envelopes +transport · elements + +physdata/ +sellmeier · equation_of_state +gases · glasses · metals +nonlinear_data · raman_types +species · mixtures · transport +selection + +modes/ +protocol · base · capillary +antiresonant · tabulated · fill +stepindex · material · vector +guide · graded_index · connection +rectangular · slab · collection +quadrature + +nonlinear/ +responses · ionisation +optmat_rates · envelope_plasma +raman · gnlse · normalisation +mixtures · optmat_raman · tdse + + +T +translation +may import +P, numpy, scipy + +translate/ +problem · routes · stage · linop +expansion · chebyshev · modal +modal_tables · modal_path · coupled +general · radial · free · planar +spatial · transverse · collar_check +flux · coupling · responses +ionisation · plasma_integration +plasma_source · envelope_plasma +raman · masks · tdse · tdse_source +run_request + + +B +backends +may import +numpy; the native package in native/ + +backends/ +protocol · registry · spatial +populations · memory · node_chunk +multistep · adams_core · rk_core +exponential · low_storage · session +events · export · session_core +sessions + +backends/host/ +driver · adams · general +low_storage · group · session +group_session + +backends/numpy_ref/ +backend · batch · passes +admission · envelope_plasma +general_modal · tdse + + +D +driver +may import +P, T +B through the seam, h5py + +log.py + +stats.py + +stepper/ +tableau · runge_kutta · multistep +low_storage · controller · schedule + +output/ +schema · capability · memory +callback · hdf5 · tdse_archive +snapshots · scan · scan_artifacts +resumable + +scan.py + +calibration/ +command · controller · first_use +child · grain · problems · refine +selection + +driver/executors/ +local · pool · queue · slurm +batch + +driver/ +simulation · run · summary +interface · scaffold · mixtures +material_output · tdse +tdse_consistency · scan · batch +defaults · estimate · plane_view +service + +recipe/ +core · document · errors · export +metadata · paths · pins · preview +provenance · quantity · registry + +cli.py + + +X +processing / plotting +may import +numpy, scipy +matplotlib, D (outputs) +T (table identities) + +processing/ +beams · axes · fields · spectra +time · coherence · compression +mixtures · modal · planes +plane_file · scans + +plotting/ +propagation · statistics +spectrograms · style · geometry + +sol.py + +capabilities.py + +service/ +agent · beam · beams · client +design · explore · figures · frames +handlers · install · job · kernel +launch · machines · messages +postprocess · recipes · record +reduce · results · run · runs +scan_reductions · sheet · states +waveguide · worker + +processing/ +windows + + +T → P physics objects + + +B → T delta_phi, prefactor_at, the stage bundle + + +D → B backends.select, then the seam + + +X → D a saved run, through the Output protocol + + + +1 + + + +2 + + + +3 + + + +4 +1 Simulation calls translate.assemble +2 the driver builds the grid, the modes, the pulse and the responses +3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object +4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X +The package-wide modules carry no layer letter: __init__.py · exceptions.py \ No newline at end of file diff --git a/src/solprop/recipe/pins.py b/src/solprop/recipe/pins.py index 4db35cb3..e271867a 100644 --- a/src/solprop/recipe/pins.py +++ b/src/solprop/recipe/pins.py @@ -603,7 +603,7 @@ def _at(mode: "Mode", parameter: str, value: float) -> "Mode": - ``parameter``: ``"pressure"`` (bar) or a mode parameter such as ``"core_radius"`` (m). """ - from solprop.tools import _capillary_at # noqa: PLC0415 - the tools' own re-fill + from solprop.tools.design import _capillary_at # noqa: PLC0415 - the re-fill if parameter == "pressure": return _capillary_at(cast("object", mode), value) # type: ignore[arg-type] @@ -616,7 +616,8 @@ def _gamma(mode: "Mode", wavelength: float) -> float: - ``wavelength``: the pump wavelength, m. - Returns: ``gamma`` in rad/(W m). """ - from solprop.tools import _fill_n2, nonlinear_coefficient # noqa: PLC0415 + from solprop.tools import nonlinear_coefficient # noqa: PLC0415 + from solprop.tools.waveguide import _fill_n2 # noqa: PLC0415 # The capillary and waveguide routes bind every mode to their fill. mixture = cast("Mixture", getattr(mode, "mixture", None)) diff --git a/src/solprop/recipe/preview.py b/src/solprop/recipe/preview.py index e53c01ae..4fa24f1a 100644 --- a/src/solprop/recipe/preview.py +++ b/src/solprop/recipe/preview.py @@ -297,10 +297,10 @@ def _pump( # noqa: PLR0911 - one return per case of the linear condition from solprop.recipe.pins import _route_pulse # noqa: PLC0415 from solprop.tools import ( # noqa: PLC0415 SolitonOrder, - _fill_n2, nonlinear_coefficient, phase_mismatch, ) + from solprop.tools.waveguide import _fill_n2 # noqa: PLC0415 wavelength = keywords.get("wavelength") if not isinstance(wavelength, int | float) or soliton is None: @@ -398,7 +398,7 @@ def _gamma( - Returns: ``gamma`` in rad/(W m); ``None`` without a fill or its Kerr data. """ from solprop.physdata.mixtures import Mixture # noqa: PLC0415 - from solprop.tools import _fill_n2 # noqa: PLC0415 - the tools' own n2 + from solprop.tools.waveguide import _fill_n2 # noqa: PLC0415 - the tools' own n2 mixture = getattr(mode, "mixture", None) if not isinstance(mixture, Mixture): diff --git a/src/solprop/tools.py b/src/solprop/tools.py deleted file mode 100644 index 0159c349..00000000 --- a/src/solprop/tools.py +++ /dev/null @@ -1,3600 +0,0 @@ -# SPDX-FileCopyrightText: 2026 John C. Travers and contributors -# -# SPDX-License-Identifier: GPL-3.0-or-later - -r"""Design calculators: soliton numbers and dispersive-wave phase matching. - -The numbers a person works out before a run. For a pulse of energy :math:`E` and -full width at half maximum :math:`\tau_{\rm fwhm}` in a mode with group-velocity -dispersion :math:`\beta_2`, effective area :math:`A_{\rm eff}` and a fill of nonlinear -index :math:`n_2`, - -.. math:: - \gamma = \frac{n_2\omega_0}{cA_{\rm eff}},\quad - L_D = \frac{\tau_0^2}{|\beta_2|},\quad - L_{NL} = \frac{1}{\gamma P_0},\quad - N = \sqrt{L_D/L_{NL}},\quad - L_{\rm fiss} = L_D/N, - -with the natural width :math:`\tau_0` and the peak power :math:`P_0` of the chosen -pulse shape (:func:`pulse_width`, :func:`pulse_peak_power`). One shape runs through the -whole chain, so the Gaussian numbers are never computed with a ``sech`` width. - -A dispersive wave is phase matched to a soliton at the frequencies where - -.. math:: - \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) - - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} - -vanishes (:func:`phase_mismatch`). The last term is the soliton's nonlinear wavenumber -and uses the largest unchirped constituent when pulse data are given -(:func:`soliton_content`). Without pulse data the condition is linear. -``docs/guide/workflows/design-tools.md`` derives both and says what the nonlinear term -assumes. - -Everything is SI except pressure, which is in bar as at the rest of the user interface -(``docs/dev/conventions.md`` section 1). -""" - -import logging -import math -import warnings -from collections.abc import Callable, Iterator, Mapping -from contextlib import contextmanager -from dataclasses import dataclass -from itertools import pairwise -from typing import Literal, cast, get_args - -import numpy as np -from jaxtyping import Float -from scipy.integrate import solve_ivp -from scipy.optimize import brentq -from scipy.special import erfcinv - -from solprop import spatial -from solprop._display import Displayed, line -from solprop._validation import choice, positive -from solprop.constants import EPS0, ROOM_TEMPERATURE, C -from solprop.exceptions import ( - DiluteScalingWarning, - MaterialError, - MaterialRangeWarning, - ModeError, - ToolsError, -) -from solprop.modes import base -from solprop.modes.antiresonant import TubeLattice, VincettiMode -from solprop.modes.capillary import LossSpec, MarcatiliMode, MarcatiliModel, ModeKind -from solprop.modes.protocol import Mode -from solprop.modes.quadrature import BASIS_NORMALISATION, mode_matrix, on_axis_rule -from solprop.physdata import ( - Gas, - Mixture, - chi3, - chi3_from_n2, - n2_from_chi3, - refractive_index, - species, -) -from solprop.physdata.glasses import GlassIndex, fused_silica -from solprop.physdata.nonlinear_data import carriers_agree -from solprop.physdata.selection import MaterialSelection -from solprop.profiles import ZProfile -from solprop.pulses import GaussianPulse, SechPulse - -__all__ = [ - "RDW", - "ZDW", - "BeamParams", - "DesignBuild", - "DesignRoots", - "DesignTarget", - "Fill", - "GasRatio", - "NonlinearPhase", - "PulseShape", - "RootChoice", - "SolitonContent", - "SolitonOrder", - "SolitonParams", - "beam_params", - "beam_through_lens", - "capillary_design", - "capillary_params", - "dispersion_length", - "field_to_intensity", - "fission_length", - "gas_ratio", - "intensity_to_field", - "lambda_rdw", - "lambda_rdw_all", - "nonlinear_coefficient", - "nonlinear_length", - "phase_mismatch", - "pressure_rdw", - "pressure_zdw", - "pulse_energy", - "pulse_peak_power", - "pulse_width", - "radius_zdw", - "self_focusing_power", - "soliton_content", - "soliton_order", - "soliton_params", - "soliton_peak_power", - "solve_parameter", -] - -logger = logging.getLogger(__name__) - -PulseShape = Literal["sech", "gauss"] -"""The two pulse shapes the design numbers are defined for.""" - -NonlinearPhase = Literal["largest", "supplied_power", "linear"] -"""Choose the constituent, supplied-power or linear RDW phase model.""" - -Fill = Gas | str | Mapping[str, float] | Mixture -"""A fill as the run interfaces accept it: a species, mole fractions or a mixture.""" - -_SECH_WIDTH = 2.0 * math.log(1.0 + math.sqrt(2.0)) -"""``fwhm / tau0`` of ``sech^2(t / tau0)``: twice the inverse cosh of the root of 2.""" - -_GAUSS_WIDTH = 2.0 * math.sqrt(math.log(2.0)) -"""``fwhm / tau0`` of ``exp(-t^2 / tau0^2)``.""" - -_MISMATCH_SAMPLES = 2048 -"""Frequency samples the phase-matching search brackets its roots on. - -The mismatch of a hollow waveguide is smooth on the scale of the search window (its -structure is set by the zero-dispersion points, of which there are a handful), so two -roots closer than a two-thousandth of the window are not separated. The cost is one -vectorised index evaluation. -""" - -_PRESSURE_FLOOR = 1e-6 -"""Lower end of the pressure brackets in bar; a true vacuum has no gas term.""" - -_ZDW_RANGE = (100e-9, 3e-6) -"""Window of the zero-dispersion search in m: :func:`modes.base.zdw`'s.""" - -_ZDW_SAMPLES = 512 -"""Samples, evenly spaced in frequency, on which the dispersion's sign is read.""" - -_ZDW_REFINE_ROUNDS = 4 -"""Rounds of array samples that refine a sign change of the dispersion. Four take a -bracket of 3.6e13 rad/s to 3.4e7 rad/s, where interpolating linearly reaches the noise -of the differenced ``beta2``.""" - -_ZDW_REFINE_SAMPLES = 33 -"""Samples of each refinement round: 32 intervals, so each round narrows 32-fold.""" - -_POLE_FACTOR = 2.0 -"""How far the group delay at a refined sign change of ``beta2`` may sit from its value -at the bracket's ends, in units of the change a bounded ``beta2`` could produce across -the bracket, before the sign change is read as a pole rather than a zero. At a zero -``beta1`` is extremal and moves by at most half that unit; at a gas resonance it -diverges. The differenced ``beta2`` itself cannot tell them apart: ``beta1`` is even -about a pole, so its central difference vanishes there too.""" - -_SEARCH_SAMPLES = 129 -"""Linear samples of a parameter search, and logarithmic ones beside them where the -range is positive: :func:`pressure_rdw`'s grid, which :func:`solve_parameter` shares.""" - -_ATTAINABLE_SAMPLES = 17 -"""Linear samples, and logarithmic ones beside them, on which a search without a root -reports the attainable range of its target's quantity. Coarser than the search: the -report rebuilds each mode, and the zero-dispersion search alone costs some tens of -index evaluations per mode. Both ends of the range are among them, which is where a -quantity monotonic in the parameter takes its extremes.""" - -_ROOT_TOLERANCE = 2e-12 -"""Brent's absolute tolerance on a bracketed root, in units of the bracket's larger -end: the default ``xtol`` of :func:`scipy.optimize.brentq` (2e-12) made relative, so a -radius in m and a pressure in bar converge to the same number of digits.""" - -_ROOT_RESIDUAL = 1e-3 -"""Largest residual at a refined sign change, as a fraction of its larger value at the -bracket's ends, for the change to be a root. Brent's method converges onto a pole as -readily as onto a zero; at a zero the residual it ends on is of the order of the slope -times the tolerance, many orders below the ends, and at a pole it diverges past both. -A parameter has no integral like ``beta1`` to test the change against (see -:data:`_POLE_FACTOR`), so the residual's own size decides.""" - - -@contextmanager -def _probing() -> Iterator[None]: - """Silence material-range and dilute-scaling warnings while a search samples. - - A root search evaluates the index across a window far wider than its answer -- - in frequency, or in pressure up to a hundred bar, where an optmat fill's linear - density scaling departs from its finite-density law -- and the ends are probes, - not results. The answer itself is re-evaluated outside this context, so a root - that lies outside a model's range, or at a density beyond the dilute law, - still warns. - """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore", MaterialRangeWarning) - warnings.simplefilter("ignore", DiluteScalingWarning) - yield - - -def _omega(wavelength: float) -> float: - """Return the angular frequency of a vacuum wavelength. - - - ``wavelength``: vacuum wavelength in m, > 0. - - Returns: angular frequency in rad/s. - """ - return 2.0 * math.pi * C / positive(wavelength, "wavelength", ToolsError) - - -def pulse_width(fwhm: float, shape: PulseShape = "sech") -> float: - r"""Return the natural width of a pulse from its full width at half maximum. - - Parameters - ---------- - fwhm : float - Full width at half maximum of the **power** in s, > 0. - shape : {'sech', 'gauss'}, optional - ``"sech"`` for :math:`P\propto{\rm sech}^2(t/\tau_0)`, ``"gauss"`` for - :math:`P\propto\exp(-t^2/\tau_0^2)`. - - Returns - ------- - float - :math:`\tau_0` in s: ``fwhm / (2 ln(1 + sqrt 2))`` or ``fwhm / (2 sqrt(ln 2))``. - - Raises - ------ - ToolsError - If the shape is unknown or the width is not positive. - - Examples - -------- - >>> from solprop.tools import pulse_width - >>> f"{pulse_width(10e-15) * 1e15:.4f}" - '5.6730' - >>> f"{pulse_width(10e-15, 'gauss') * 1e15:.4f}" - '6.0056' - """ - choice(shape, "shape", get_args(PulseShape), ToolsError) - return positive(fwhm, "fwhm", ToolsError) / ( - _SECH_WIDTH if shape == "sech" else _GAUSS_WIDTH - ) - - -def pulse_peak_power(energy: float, fwhm: float, shape: PulseShape = "sech") -> float: - r"""Return the peak power of a pulse of given energy, duration and shape. - - Parameters - ---------- - energy : float - Pulse energy in J, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. - - Returns - ------- - float - Peak power in W: :math:`E/(2\tau_0)` for ``sech`` and - :math:`E/(\sqrt\pi\,\tau_0)` for a Gaussian, the integrals of the two shapes. - - Raises - ------ - ToolsError - If the shape is unknown or a quantity is not positive. - - Examples - -------- - >>> from solprop.tools import pulse_peak_power - >>> f"{pulse_peak_power(1e-3, 10e-15) / 1e9:.3f}" - '88.137' - >>> f"{pulse_peak_power(1e-3, 10e-15, 'gauss') / 1e9:.3f}" - '93.944' - """ - width = pulse_width(fwhm, shape) - integral = 2.0 if shape == "sech" else math.sqrt(math.pi) - return positive(energy, "energy", ToolsError) / (integral * width) - - -def pulse_energy(peak_power: float, fwhm: float, shape: PulseShape = "sech") -> float: - """Return the energy of a pulse of given peak power, duration and shape. - - The inverse of :func:`pulse_peak_power`, for the same shape. - - Parameters - ---------- - peak_power : float - Peak power in W, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. - - Returns - ------- - float - Pulse energy in J. - - Raises - ------ - ToolsError - If the shape is unknown or a quantity is not positive. - - Examples - -------- - >>> from solprop.tools import pulse_energy - >>> f"{pulse_energy(88.137e9, 10e-15) * 1e3:.4f}" - '1.0000' - """ - # The peak power is linear in the energy at a fixed width and shape. - return positive(peak_power, "peak_power", ToolsError) / pulse_peak_power( - 1.0, fwhm, shape - ) - - -def dispersion_length(fwhm: float, beta2: float, shape: PulseShape = "sech") -> float: - r"""Return the dispersion length :math:`L_D = \tau_0^2/|\beta_2|`. - - Parameters - ---------- - fwhm : float - Full width at half maximum of the power in s, > 0. - beta2 : float - Group-velocity dispersion in s^2/m, non-zero. - shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. - - Returns - ------- - float - Dispersion length in m. - - Raises - ------ - ToolsError - If the dispersion is zero or not finite, or the width is not positive. - - Examples - -------- - >>> from solprop.tools import dispersion_length - >>> f"{dispersion_length(10e-15, -8e-30):.3f}" - '4.023' - """ - if not math.isfinite(beta2) or beta2 == 0.0: - raise ToolsError(f"beta2 must be finite and non-zero, got {beta2!r}") - return pulse_width(fwhm, shape) ** 2 / abs(beta2) - - -def nonlinear_length(peak_power: float, gamma: float) -> float: - r"""Return the nonlinear length :math:`L_{NL} = 1/(\gamma P_0)`. - - Parameters - ---------- - peak_power : float - Peak power in W, > 0. - gamma : float - Nonlinear coefficient in rad/(W m), > 0. - - Returns - ------- - float - Nonlinear length in m. - - Raises - ------ - ToolsError - If either quantity is not positive. - - Examples - -------- - >>> from solprop.tools import nonlinear_length - >>> nonlinear_length(1e9, 1e-9) - 1.0 - """ - return 1.0 / ( - positive(gamma, "gamma", ToolsError) - * positive(peak_power, "peak_power", ToolsError) - ) - - -def soliton_order( - peak_power: float, - fwhm: float, - gamma: float, - beta2: float, - shape: PulseShape = "sech", -) -> float: - r"""Return the soliton order :math:`N = \sqrt{L_D/L_{NL}}`. - - The number is defined for either sign of the dispersion; it is a - soliton order in the physical sense only where ``beta2 < 0``. - - Parameters - ---------- - peak_power : float - Peak power in W, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - gamma : float - Nonlinear coefficient in rad/(W m), > 0. - beta2 : float - Group-velocity dispersion in s^2/m, non-zero. - shape : {'sech', 'gauss'}, optional - The pulse shape, used for the width **throughout**. - - Returns - ------- - float - The soliton order, dimensionless. - - Examples - -------- - >>> from solprop.tools import soliton_order - >>> f"{soliton_order(1e9, 10e-15, 1e-9, -8e-30):.4f}" - '2.0057' - """ - return math.sqrt( - dispersion_length(fwhm, beta2, shape) / nonlinear_length(peak_power, gamma) - ) - - -def soliton_peak_power( - order: float, - fwhm: float, - gamma: float, - beta2: float, - shape: PulseShape = "sech", -) -> float: - r"""Return the peak power that gives a soliton order. - - The inverse of :func:`soliton_order` at fixed dispersion, nonlinear coefficient - and width: :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|` is linear in the peak - power, so :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. :func:`pulse_energy` - turns it into an energy. On a mode, take `beta2` from - :func:`solprop.modes.base.beta2` at the pump and `gamma` from - :func:`nonlinear_coefficient`, both at the launch position. - - Parameters - ---------- - order : float - Conventional soliton order, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - gamma : float - Nonlinear coefficient in rad/(W m), > 0. - beta2 : float - Group-velocity dispersion in s^2/m, non-zero. - shape : {'sech', 'gauss'}, optional - The pulse shape, used for the width. - - Returns - ------- - float - Peak power in W. - - Raises - ------ - ToolsError - If a quantity is not positive, the dispersion is zero, or the shape is - unknown. - - Examples - -------- - >>> from solprop.tools import soliton_order, soliton_peak_power - >>> power = soliton_peak_power(2.0, 10e-15, 1e-9, -8e-30) - >>> f"{power / 1e9:.4f} {soliton_order(power, 10e-15, 1e-9, -8e-30):.4f}" - '0.9943 2.0000' - """ - return positive(order, "order", ToolsError) ** 2 / ( - positive(gamma, "gamma", ToolsError) * dispersion_length(fwhm, beta2, shape) - ) - - -@dataclass(frozen=True) -class SolitonContent: - """Describe the discrete soliton content of an unchirped single-lobe pulse. - - Attributes - ---------- - area_order : float - Dimensionless field area divided by pi, not the conventional soliton order. - count : int - Number of strictly positive discrete eigenvalues; zero at first threshold. - largest_eigenvalue : float or None - Imaginary part of the largest Zakharov-Shabat eigenvalue, dimensionless - with time scaled by the input shape's natural width. None if count is zero. - largest_peak_power : float or None - Peak power of the largest constituent in W, or None if count is zero. - largest_width : float or None - Natural sech width of that constituent in s, or None if count is zero. - largest_nonlinear_wavenumber : float or None - Positive optics-convention phase rate in 1/m, or None if count is zero. - """ - - area_order: float - count: int - largest_eigenvalue: float | None - largest_peak_power: float | None - largest_width: float | None - largest_nonlinear_wavenumber: float | None - - -def _scattering_angle( - x: float, theta: Float[np.ndarray, " n_components"], order: float, eta: float -) -> list[float]: - """Evaluate the real Zakharov-Shabat phase equation for a Gaussian.""" - return [-order * math.exp(-x * x / 2.0) - eta * math.sin(2 * theta[0])] - - -def _gaussian_eigenvalue(order: float) -> float: - """Solve the half-line scattering problem for the largest Gaussian eigenvalue. - - - ``order``: conventional soliton order, above the first Gaussian threshold. - """ - # The omitted field area is <= 1e-13. Moving the boundary with the amplitude - # avoids assuming that the Gaussian tail is negligible for every input N. - extent = math.sqrt(2.0) * float(erfcinv(1e-13 / (order * math.sqrt(math.pi / 2.0)))) - - def residual(eta: float) -> float: - """Return the ground-state symmetry residual at the pulse centre.""" - if eta == 0.0: - # The exact area protects the first threshold from tail truncation. - return math.pi / 4.0 - order * math.sqrt(math.pi / 2.0) - solution = solve_ivp( - _scattering_angle, - (-extent, 0.0), - [0.0], - method="DOP853", - args=(order, eta), - rtol=1e-11, - atol=1e-13, - ) - if not solution.success: - raise ToolsError( - f"Gaussian scattering integration failed: {solution.message}" - ) - return float(solution.y[0, -1]) + math.pi / 4.0 - - # The ground-state angle crosses -pi/4 once for 0 < eta < max(q). - # Unlike shooting the amplitudes, this does not overflow in the tails. - try: - return float(brentq(residual, 0.0, order, xtol=1e-13, rtol=1e-11)) - except (ValueError, RuntimeError) as error: - raise ToolsError(f"Gaussian eigenvalue refinement failed: {error}") from error - - -def soliton_content( - peak_power: float, - fwhm: float, - gamma: float, - beta2: float, - shape: PulseShape = "sech", -) -> SolitonContent: - r"""Calculate the soliton count and largest constituent of an unchirped pulse. - - This is the scattering content of the lossless cubic focusing NLSE, not a - prediction of fission dynamics with higher-order dispersion, Raman or plasma. - The conventional :func:`soliton_order` remains a separate design parameter. - - Parameters - ---------- - peak_power : float - Input peak power in W, finite and positive. - fwhm : float - Input power FWHM in s, finite and positive. - gamma : float - Nonlinear coefficient in rad/(W m), finite and positive. - beta2 : float - Anomalous group-velocity dispersion in s^2/m, finite and negative. - shape : {'sech', 'gauss'}, optional - Unchirped input field shape; the Gaussian power is exp(-t^2/tau0^2). - - Returns - ------- - SolitonContent - Analytic count and largest constituent. The sech eigenvalue is analytic; - the Gaussian eigenvalue is numerical, with dimensionless absolute accuracy - about 1e-10 away from thresholds. Relative accuracy degrades at threshold. - - Raises - ------ - ToolsError - If inputs are invalid, dispersion is not anomalous, or scattering fails. - - References - ---------- - .. [1] Klaus and Shaw, SIAM J. Math. Anal. 34, 759 (2003), - doi:10.1137/S0036141002403067. - .. [2] Kim, Lee and Lyng, arXiv:1310.4145, sections 1-2. - """ - if not math.isfinite(beta2) or beta2 >= 0.0: - raise ToolsError( - "soliton content requires finite anomalous dispersion (beta2 < 0)" - ) - order = soliton_order(peak_power, fwhm, gamma, beta2, shape) - area_order = order if shape == "sech" else math.sqrt(2.0 / math.pi) * order - if not math.isfinite(area_order): - raise ToolsError("the normalised pulse area must be finite") - # Strictly positive eigenvalues only: at a half-integer area the new state is - # at zero and is not a square-integrable soliton (Klaus-Shaw threshold). - count = max(0, math.ceil(area_order - 0.5)) - if count == 0: - return SolitonContent(area_order, 0, None, None, None, None) - eta = order - 0.5 if shape == "sech" else _gaussian_eigenvalue(order) - if eta <= 0.0: - raise ToolsError( - "the positive Gaussian eigenvalue is too close to threshold to resolve" - ) - power = peak_power * (2.0 * eta / order) ** 2 - width = pulse_width(fwhm, shape) / (2.0 * eta) - return SolitonContent(area_order, count, eta, power, width, 0.5 * gamma * power) - - -def fission_length( - peak_power: float, - fwhm: float, - gamma: float, - beta2: float, - shape: PulseShape = "sech", -) -> float: - r"""Return the soliton fission length :math:`L_{\rm fiss} = L_D/N`. - - Parameters - ---------- - peak_power : float - Peak power in W, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - gamma : float - Nonlinear coefficient in rad/(W m), > 0. - beta2 : float - Group-velocity dispersion in s^2/m, non-zero. - shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. - - Returns - ------- - float - Fission length in m, the geometric mean of the two lengths. - - Examples - -------- - >>> from solprop.tools import fission_length - >>> f"{fission_length(1e9, 10e-15, 1e-9, -8e-30):.4f}" - '2.0057' - """ - order = soliton_order(peak_power, fwhm, gamma, beta2, shape) - return dispersion_length(fwhm, beta2, shape) / order - - -def nonlinear_coefficient( - mode: Mode, n2: float, wavelength: float, z: float = 0.0 -) -> float: - r"""Return the nonlinear coefficient :math:`\gamma = n_2\omega/(cA_{\rm eff})`. - - Parameters - ---------- - mode : Mode - The mode, for its effective area. - n2 : float - Nonlinear index of the fill in m^2/W, >= 0. - wavelength : float - Vacuum wavelength in m, > 0. - z : float, optional - Position along the waveguide, m. - - Returns - ------- - float - Nonlinear coefficient in rad/(W m). - - Raises - ------ - ToolsError - If the nonlinear index is negative or not finite. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import nonlinear_coefficient - >>> mode = MarcatiliMode(125e-6, "He", 1.0) - >>> f"{nonlinear_coefficient(mode, 3.55e-25, 800e-9):.4e}" - '1.1919e-10' - """ - if not math.isfinite(n2) or n2 < 0.0: - raise ToolsError(f"n2 must be finite and not negative, got {n2!r}") - return n2 * _omega(wavelength) / (C * base.aeff(mode, z)) - - -def self_focusing_power(wavelength: float, index: float, n2: float) -> float: - r"""Return the critical power for self-focusing, as physical power. - - :math:`P_{\rm cr} = 1.86226\,\lambda^2/(4\pi n_0 n_2)`, the Townes power of Fibich - and Gaeta (Opt. Lett. 25, 335 (2000)). It is - :func:`solprop.spatial.critical_power` -- the package's one Townes constant -- - at the linear index, both measuring the physical axial power in the medium - (SEP 34). The paper prints the constant as 1.86225; the - computed value is 1.8622555, of which 1.86226 is the rounding. - - Parameters - ---------- - wavelength : float - Vacuum wavelength in m, > 0. - index : float - Linear refractive index :math:`n_0`, > 0. - n2 : float - Nonlinear index in m^2/W, > 0. - - Returns - ------- - float - Critical power in W. - - Examples - -------- - >>> from solprop.tools import self_focusing_power - >>> f"{self_focusing_power(800e-9, 1.0, 3.55e-25) / 1e9:.2f}" - '267.17' - """ - linear = positive(index, "index", ToolsError) - chi3 = chi3_from_n2(positive(n2, "n2", ToolsError), linear) - return spatial.critical_power( - positive(wavelength, "wavelength", ToolsError), chi3, index=linear - ) - - -@dataclass(frozen=True) -class BeamParams: - r"""The characteristic numbers of a Gaussian beam in a gas. - - Attributes - ---------- - waist : float - The 1/e² intensity radius at the waist, m. - focus : float - Where the waist lies from the launch plane, m. - wavelength : float - The vacuum wavelength, m. - index : float - The linear refractive index. - rayleigh_range : float - :math:`\pi n w_0^2/\lambda`, m. - critical_power : float - The Townes critical power, W (:func:`solprop.spatial.critical_power`). - peak_power : float - The beam's peak power, W. - power_ratio : float - Its peak power over the critical power. - collapse_distance : float or None - Where a monochromatic beam in a pure Kerr medium would collapse by Marburger's - empirical formula, m from the launch plane, with a lens's focus combined by - Talanov's law. A reference, not a prediction of a broadband, ionising run; - ``None`` at or below the critical power, or where a beam already diverging - leaves no collapse ahead. - """ - - waist: float - focus: float - wavelength: float - index: float - rayleigh_range: float - critical_power: float - peak_power: float - power_ratio: float - collapse_distance: float | None - - def radius(self, z: Float[np.ndarray, "*shape"]) -> Float[np.ndarray, "*shape"]: - """Return the linear beam radius along z, in the absence of any nonlinearity. - - Parameters - ---------- - z : numpy.ndarray - Positions from the launch plane, m. - - Returns - ------- - numpy.ndarray - The 1/e² intensity radius, m. - """ - offset = (np.asarray(z, dtype=float) - self.focus) / self.rayleigh_range - return self.waist * np.sqrt(1.0 + offset**2) - - -def beam_params( - waist: float, - wavelength: float, - gas: Gas | str, - pressure: float, - peak_power: float, - *, - focus: float = 0.0, -) -> BeamParams: - r"""Return the characteristic numbers of a Gaussian beam in a gas. - - The Rayleigh range, the critical power, and Marburger's collapse distance - :math:`0.367 z_R/\sqrt{(\sqrt{P/P_{\rm cr}}-0.852)^2-0.0219}`, for a beam collimated - at the launch plane. A beam launched a distance :math:`d` before its waist has, at - launch, the radius :math:`w_0\sqrt{1 + (d/z_R)^2}` and a wavefront converging with - radius :math:`R = d + z_R^2/d`; Marburger's distance for that radius is combined - with :math:`R` by Talanov's lens law, :math:`1/z_c' = 1/z_c + 1/R`. A waist just - ahead is nearly collimated, so the distance moves smoothly with :math:`d`. - - Parameters - ---------- - waist : float - The 1/e² intensity radius at the waist, m. - wavelength : float - The vacuum wavelength, m. - gas : Gas or str - The gas. - pressure : float - Its pressure, bar. - peak_power : float - The beam's peak power, W. - focus : float, optional - Where the waist lies from the launch plane, m. - - Returns - ------- - BeamParams - The numbers. - - Examples - -------- - >>> from solprop.tools import beam_params - >>> beam = beam_params(300e-6, 800e-9, "Ar", 1.0, 27.6e9) - >>> f"{beam.rayleigh_range:.3f} m, P/Pcr {beam.power_ratio:.2f}" - '0.354 m, P/Pcr 3.00' - >>> f"{beam.collapse_distance:.3f} m" - '0.150 m' - """ - omega = _omega(wavelength) - index = float(refractive_index(gas, omega, pressure=pressure)) - power = spatial.critical_power( - wavelength, chi3(gas, pressure=pressure, wavelength=wavelength), index=index - ) - rayleigh = np.pi * index * positive(waist, "waist", ToolsError) ** 2 / wavelength - ratio = positive(peak_power, "peak_power", ToolsError) / power - collapse = None - if ratio > 1.0: - # (sqrt(r) - 0.852)^2 - 0.0219 vanishes at r = 1, so it is positive above it. - root = (np.sqrt(ratio) - 0.852) ** 2 - 0.0219 - # At launch: the radius there, and the wavefront's radius of curvature. - launch = rayleigh * (1.0 + (focus / rayleigh) ** 2) - collapse = float(0.367 * launch / np.sqrt(root)) - if focus != 0: - curvature = focus + rayleigh**2 / focus - inverse = 1.0 / collapse + 1.0 / curvature - collapse = float(1.0 / inverse) if inverse > 0 else None - return BeamParams( - waist, - focus, - wavelength, - index, - float(rayleigh), - float(power), - peak_power, - float(ratio), - collapse, - ) - - -def beam_through_lens( - focal_length: float, radius: float, wavelength: float -) -> tuple[float, float]: - r"""Return the waist a thin lens makes of a collimated Gaussian beam. - - :math:`w_0 = \lambda f / (\pi w) / \sqrt{1 + (\lambda f / \pi w^2)^2}`, and the - waist's distance from the lens, :math:`f / (1 + (\lambda f / \pi w^2)^2)`, which - falls short of the focal length for a weakly focused beam. Paraxial, in vacuum. - - Parameters - ---------- - focal_length : float - The lens's focal length, m. - radius : float - The beam's 1/e² intensity radius on the lens, m. - wavelength : float - The vacuum wavelength, m. - - Returns - ------- - tuple of float - The waist, m, and its distance from the lens, m. - - Examples - -------- - >>> from solprop.tools import beam_through_lens - >>> waist, distance = beam_through_lens(1.0, 5e-3, 800e-9) - >>> f"{waist * 1e6:.1f} um at {distance:.4f} m" - '50.9 um at 0.9999 m' - """ - focal = positive(focal_length, "focal_length", ToolsError) - beam = positive(radius, "radius", ToolsError) - light = positive(wavelength, "wavelength", ToolsError) - ratio = light * focal / (np.pi * beam**2) - factor = 1.0 / (1.0 + ratio**2) - return float(light * focal / (np.pi * beam) * np.sqrt(factor)), float( - focal * factor - ) - - -def field_to_intensity(field: float) -> float: - r"""Return the intensity of a field amplitude, :math:`I = \epsilon_0 c E^2/2`. - - Parameters - ---------- - field : float - Field amplitude in V/m. - - Returns - ------- - float - Intensity in W/m^2. - - Examples - -------- - >>> from solprop.tools import field_to_intensity - >>> f"{field_to_intensity(1e10):.4e}" - '1.3272e+17' - """ - return 0.5 * EPS0 * C * float(field) ** 2 - - -def intensity_to_field(intensity: float) -> float: - r"""Return the field amplitude of an intensity, :math:`E = \sqrt{2I/\epsilon_0 c}`. - - Parameters - ---------- - intensity : float - Intensity in W/m^2, >= 0. - - Returns - ------- - float - Field amplitude in V/m. - - Raises - ------ - ToolsError - If the intensity is negative. - - Examples - -------- - >>> from solprop.tools import field_to_intensity, intensity_to_field - >>> f"{intensity_to_field(field_to_intensity(1e10)):.6e}" - '1.000000e+10' - """ - if not intensity >= 0.0: - raise ToolsError(f"an intensity must not be negative, got {intensity!r}") - return math.sqrt(2.0 * float(intensity) / (EPS0 * C)) - - -# ---------------------------------------------------------------- soliton parameters - - -@dataclass(frozen=True) -class GasRatio: - """Ratios of the bulk dispersion and the Kerr response of two gases. - - Attributes - ---------- - beta2 : float - Ratio of the bulk group-velocity dispersions, first gas over second. - chi3 : float - Ratio of the third-order susceptibilities, first gas over second. - """ - - beta2: float - chi3: float - - -@dataclass(frozen=True, eq=False) -class SolitonParams(Displayed): - """The design numbers of one pulse in one mode and one fill. - - Attributes - ---------- - energy : float - Pulse energy in J. - fwhm : float - Full width at half maximum of the power in s. - width : float - Natural width ``tau0`` of the shape in s (:func:`pulse_width`). - omega : float - Angular frequency of the pump in rad/s. - wavelength : float - Vacuum wavelength of the pump in m. - gas : str - The fill's species labels, joined by ``+``. - pressure : float - Total pressure at the position in bar; NaN for a mixture of independent - populations, which has no single pressure. - temperature : float - Fill temperature in K. - shape : {'sech', 'gauss'} - ``"sech"`` or ``"gauss"``. - peak_power : float - Peak power in W. - beta2 : float - Group-velocity dispersion of the mode at the pump in s^2/m. - density : float - Total number density of the fill in m^-3. - index : float - Linear refractive index of the fill at the pump. - n2 : float - Nonlinear index of the fill in m^2/W. - gamma : float - Nonlinear coefficient in rad/(W m). - soliton_order : float - ``sqrt(L_D / L_NL)``. - soliton_content : SolitonContent or None - Unchirped NLSE constituent count and largest soliton; None for normal GVD. - intensity : float - Mode-averaged intensity ``peak_power / effective_area`` in W/m^2. - peak_intensity : float - On-axis intensity of the mode at the peak power in W/m^2: what a run's - ``peak_intensity`` statistic reports at its first plane. For the fundamental - of a capillary it is 1.77 times `intensity`. NaN for a mode without a - transverse profile (a dispersion-specified one). - critical_power : float - Self-focusing power in W (:func:`self_focusing_power`). - dispersion_length : float - In m. - nonlinear_length : float - In m. - fission_length : float - In m. - zdw : float - Zero-dispersion wavelength in m within 100 nm to 3 um, or NaN when the - dispersion does not change sign there. No second, guess-seeded search is - tried. - loss_length : float - The 1/e **power** loss length of the mode at the pump in m. - effective_area : float - In m^2. - mode : Mode - The mode. - """ - - energy: float - fwhm: float - width: float - omega: float - wavelength: float - gas: str - pressure: float - temperature: float - shape: PulseShape - peak_power: float - beta2: float - density: float - index: float - n2: float - gamma: float - soliton_order: float - soliton_content: SolitonContent | None - intensity: float - peak_intensity: float - critical_power: float - dispersion_length: float - nonlinear_length: float - fission_length: float - zdw: float - loss_length: float - effective_area: float - mode: Mode - - def __repr__(self) -> str: - """Return a one-line description: the pulse, the fill and the soliton order. - - Returns - ------- - str - For example ``SolitonParams(300 uJ, 10 fs at 800 nm, He 0.4 bar, N=2.23)``. - """ - return line( - "SolitonParams", - [ - ("", f"{self.energy * 1e6:.4g} uJ"), - ("", f"{self.fwhm * 1e15:.4g} fs at {self.wavelength * 1e9:.4g} nm"), - ("", f"{self.gas} {self.pressure:.4g} bar"), - ("N", f"{self.soliton_order:.3g}"), - ], - ) - - def __str__(self) -> str: - """Return the parameter summary, section by section. - - Returns - ------- - str - MODE, WAVEGUIDE, FILL, PULSE, DISPERSION, POWER, INTENSITY and SOLITON, - in the customary units: ``n2`` in cm^2/W, intensities in W/cm^2. - """ - zdw = ( - "none in 100-3000 nm" - if math.isnan(self.zdw) - else f"{self.zdw * 1e9:.1f} nm" - ) - ratio = self.peak_power / self.critical_power - sections = ( - ("MODE", repr(self.mode)), - ( - "WAVEGUIDE", - f"Aeff = {self.effective_area:.2e} m^2, " - f"Lloss = {self.loss_length:.2e} m (1/e power)", - ), - ( - "FILL", - f"{self.pressure:.3g} bar {self.gas} at {self.temperature:g} K, " - f"Pcr = {self.critical_power:.2e} W,\n " - f"gamma = {self.gamma:.2e} /(W m), n2 = {self.n2 * 1e4:.2e} cm^2/W", - ), - ( - "PULSE", - f"{self.energy:.3e} J, {self.fwhm:.3e} s @ " - f"{self.wavelength * 1e9:.1f} nm (shape: {self.shape})", - ), - ( - "DISPERSION", - f"{self.beta2:.3e} s^2/m @ {self.wavelength * 1e9:.1f} nm, ZDW = {zdw}", - ), - ("POWER", f"{self.peak_power:.2e} W ({ratio:.4f} of Pcr)"), - ( - "INTENSITY", - f"{self.intensity * 1e-4:.2e} W/cm^2 mode-averaged, " - f"{self.peak_intensity * 1e-4:.2e} W/cm^2 on axis", - ), - ( - "SOLITON", - f"Ld = {self.dispersion_length:.2e} m, Lnl = " - f"{self.nonlinear_length:.2e} m, Lfiss = {self.fission_length:.2e} m, " - f"N = {self.soliton_order:.2f}", - ), - ) - content = self.soliton_content - if content is None: - constituents = "unavailable (normal dispersion)" - elif content.count == 0: - constituents = "0 discrete solitons" - else: - constituents = ( - f"{content.count} discrete solitons, " - f"area/pi = {content.area_order:.3g}\n " - f"largest P = {content.largest_peak_power:.3e} W, " - f"T = {content.largest_width:.3e} s, " - f"kNL = {content.largest_nonlinear_wavenumber:.3e} 1/m" - ) - return "\n".join(f"{name}:\n {text}" for name, text in sections) + ( - f"\nCONSTITUENTS (unchirped NLSE):\n {constituents}" - ) - - -def _resolve_fill( - gas: Fill, - pressure: float | None, - temperature: float | None, - materials: MaterialSelection | None = None, -) -> Mixture: - """Return the mixture a fill declaration means, as the run interfaces read it. - - - ``pressure``: total pressure in bar; must be ``None`` beside a `Mixture`, which - carries its own, and defaults to one bar otherwise. - - ``temperature``: fill temperature in K; ``None`` is room temperature, or the - mixture's own beside a `Mixture`, which refuses any other. - - ``materials``: resolve a name, a fraction mapping's names and ``"air"`` from - optmat under this selection, as the drivers' ``capillary_fill`` does; ``None`` is - ``MaterialSelection()``. A mixture is taken as it is. - """ - if isinstance(gas, Mixture): - if pressure is not None: - raise ToolsError("a Mixture carries its own pressure; omit pressure") - if isinstance(gas.temperature, ZProfile): - raise ToolsError( - "the design calculators take one temperature in K, and this mixture's " - "varies along z; give a Mixture at one temperature" - ) - if temperature is not None and float(temperature) != gas.temperature: - # As the run interfaces: the mixture's densities were set at its own - # temperature, so another one cannot be honoured. - raise ToolsError( - f"the Mixture carries its own temperature ({gas.temperature:g} K), " - f"and temperature={temperature!r} was given beside it: omit " - "temperature=, or build the Mixture at the temperature you mean" - ) - return gas - temperature = ROOM_TEMPERATURE if temperature is None else temperature - total = 1.0 if pressure is None else float(pressure) - if isinstance(gas, Mapping): - fractions = { - species(name, selection=materials): fraction - for name, fraction in gas.items() - } - return Mixture.fill(fractions, total, temperature=temperature) - if gas is Gas.Air or (isinstance(gas, str) and gas.strip().lower() == "air"): - return Mixture.air( - total, temperature=temperature, argon=False, selection=materials - ) - return Mixture.fill( - {species(gas, selection=materials): 1.0}, total, temperature=temperature - ) - - -def _check_carrier(materials: MaterialSelection | None, wavelength: float) -> None: - """Refuse a selection whose carrier is not the tool's pump wavelength. - - A run under a selection refuses a carrier other than its grid's reference - wavelength, so a design tool refuses one other than the wavelength it evaluates - the Kerr coefficient at: the two then evaluate it at the same carrier. - - - ``wavelength``: the pump wavelength in m. - """ - carrier = None if materials is None else materials.carrier_wavelength - if carrier is not None and not carriers_agree(float(carrier), wavelength): - msg = ( - f"the selection's carrier_wavelength is {carrier:g} m and the design's " - f"wavelength {wavelength:g} m: one carrier sets the Kerr coefficient of " - "every optmat species, as in a run. Leave the selection's " - "carrier_wavelength unset, or design at it" - ) - raise ToolsError(msg) - - -def _default_cladding( - cladding: GlassIndex | complex | None, -) -> GlassIndex | complex: - """Return the cladding a design's capillary takes, as the capillary driver does. - - - ``cladding``: the cladding given; ``None`` is the package default. - """ - return fused_silica() if cladding is None else cladding - - -def _fill_chi3(mixture: Mixture, wavelength: float, z: float = 0.0) -> float: - """Return a fill's density-weighted third-order susceptibility. - - - ``wavelength``: vacuum wavelength in m, the carrier at which an optmat Kerr model - is taken. - - ``z``: position in m at which the densities are evaluated. - - Returns: ``chi3`` in m^2/V^2. - """ - try: - return float(mixture.gamma3_path(wavelength)(np.array([float(z)]))[0]) - except MaterialError as error: - raise ToolsError(f"the fill has no nonlinear index: {error}") from error - - -def _fill_n2( - mixture: Mixture, wavelength: float, z: float = 0.0 -) -> tuple[float, float]: - """Return a fill's nonlinear index and its linear index at a wavelength. - - - ``wavelength``: vacuum wavelength in m, the carrier at which an optmat Kerr model - is taken. - - ``z``: position in m at which the densities are evaluated. - - Returns: ``n2`` in m^2/W, from the density-weighted ``chi3`` and the exact - ``n0^2`` of the fill, and that linear index ``n0``. - """ - chi3 = _fill_chi3(mixture, wavelength, z) - here = np.array([float(z)]) - susceptibility = mixture.susceptibility(np.array([_omega(wavelength)]), here)[0, 0] - index = float(np.real(np.sqrt(1.0 + susceptibility))) - return n2_from_chi3(chi3, index), index - - -def _on_axis_intensity(mode: Mode, peak_power: float, z: float) -> float: - """Return the on-axis intensity of a mode carrying a given power. - - - ``peak_power``: power in W. - - ``z``: position along the waveguide, m. - - Returns: intensity on the axis in W/m^2, both transverse components included; NaN - for a mode that declares no transverse profile. - """ - # The same synthesis the run's peak_intensity statistic uses: the normalised mode - # field at the origin in V/m per sqrt(W), so that eps0 c |e|^2 / 2 is the - # intensity per watt. - try: - on_axis = mode_matrix( - (mode,), on_axis_rule(mode.dimlimits(z)[0]), z, components=("x", "y") - ) - except ModeError: - # A dispersion-specified mode has an area and no transverse profile, so it has - # a mode-averaged intensity and no on-axis one. - return math.nan - per_watt = BASIS_NORMALISATION * float(np.sum(np.abs(on_axis.synthesis) ** 2)) - return per_watt * peak_power - - -def _refine_zero(mode: Mode, low: float, high: float, z: float) -> float: - """Return a sign change of the dispersion inside a bracket, by array samples. - - A mode's index models evaluate an array at about the cost of one point, so each - round narrows the bracket 32-fold for the cost of one Brent step, where a scalar - search costs a glass model evaluation per iterate. The rounds stop short of a pole - of the differenced ``beta2``, which a scalar search converges onto until the model - returns NaN. - - - ``low``, ``high``: the bracket, rad/s, whose ends have ``beta2`` of opposite - signs. - - ``z``: position along the waveguide, m. - - Returns: the first sign change upwards in frequency, rad/s, linearly interpolated - in the last bracket; or NaN when samples that are not finite separate the signs. - """ - first = last = math.nan - for _ in range(_ZDW_REFINE_ROUNDS): - grid = np.linspace(low, high, _ZDW_REFINE_SAMPLES) - sampled = base.beta2(mode, grid, z) - crossing = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) <= 0.0) - if crossing.size == 0: - return math.nan - i = int(crossing[0]) - low, high = float(grid[i]), float(grid[i + 1]) - # The first change has a nonzero sample below it, so the two cannot be equal. - first, last = float(sampled[i]), float(sampled[i + 1]) - return low - first * (high - low) / (last - first) - - -def _zdw_near(mode: Mode, omega: float, z: float) -> float: - """Return the zero-dispersion wavelength nearest a pump frequency. - - :func:`solprop.modes.base.zdw` brackets one sign change across its whole window, - which a gas resonance in the vacuum ultraviolet defeats: krypton flips the sign of - the dispersion twice near 120 nm, the ends then agree, and the zero at 769 nm of a - 14 um capillary at 15 bar is refused. Here the sign is sampled, the changes are - refined outwards from the pump, the changes through a pole are dropped, and the zero - nearest the pump is the one reported; a change further than the best so far cannot - beat it, which spares the dozens of changes an antiresonant fibre's resonances - give. Two limits of a sampled search remain: a zero and a pole inside one sample - interval (3.6e13 rad/s) restore the sign and are both missed, and a sample that is - not finite cannot bracket anything. - - - ``omega``: pump angular frequency in rad/s. - - ``z``: position along the waveguide, m. - - Returns: wavelength in m, or NaN when the dispersion has no zero in 100 nm to 3 - um. - """ - grid = np.linspace( - 2.0 * math.pi * C / _ZDW_RANGE[1], - 2.0 * math.pi * C / _ZDW_RANGE[0], - _ZDW_SAMPLES, - ) - nearest = math.nan - # The refinement converges onto a pole as readily as onto a zero, and a model may - # divide by zero exactly there; the group-delay test below tells the two apart. - with _probing(), np.errstate(divide="ignore", invalid="ignore"): - sampled = base.beta2(mode, grid, z) - changes = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) < 0.0) - # How near each bracket can bring a zero: 0 for one holding the pump. - reach = np.maximum.reduce( - [grid[changes] - omega, omega - grid[changes + 1], np.zeros(changes.size)] - ) - for i in changes[np.argsort(reach, kind="stable")]: - low, high = float(grid[i]), float(grid[i + 1]) - if not math.isnan(nearest) and max(low - omega, omega - high) >= abs( - nearest - omega - ): - break - root = _refine_zero(mode, low, high, z) - if math.isnan(root): - continue - delay = base.beta1(mode, np.array([low, root, high]), z) - bounded = max(abs(sampled[i]), abs(sampled[i + 1])) * (high - low) - closer = math.isnan(nearest) or abs(root - omega) < abs(nearest - omega) - if closer and abs(delay[1] - delay[0]) <= _POLE_FACTOR * bounded: - nearest = root - if math.isnan(nearest): - return math.nan - # Outside the probing context, so a zero beyond the index model's range warns. - base.beta(mode, np.array([nearest]), z) - return 2.0 * math.pi * C / nearest - - -def soliton_params( - mode: Mode, - gas: Fill, - pressure: float | None = None, - *, - energy: float, - fwhm: float, - wavelength: float, - shape: PulseShape = "sech", - temperature: float | None = None, - z: float = 0.0, - materials: MaterialSelection | None = None, -) -> SolitonParams: - """Return the design numbers of a pulse in any mode. - - The mode supplies the dispersion, the area and the loss; the fill supplies the - nonlinear index. They are separate arguments because a mode need not know its - fill's Kerr response (a tabulated mode does not), and the caller is trusted to - give the fill the mode was built with -- :func:`capillary_params` does both. - - Parameters - ---------- - mode : Mode - Any mode: a capillary, an antiresonant or tabulated fibre, a rectangular - guide. - gas : Gas, str, Mapping or Mixture - The fill: a registry species, ``"air"``, mole fractions, or a mixture. A - mixture of species selected from optmat takes their Kerr coefficients at - `wavelength`, the carrier. - pressure : float or None, optional - Total pressure in **bar**; ``None`` is one bar, and is required beside a - `Mixture`. - energy : float - Pulse energy in J, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - wavelength : float - Pump wavelength in m, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape, used consistently for the width and the peak power. - temperature : float or None, optional - Fill temperature in K; ``None`` is room temperature, or the mixture's own - beside a `Mixture`, which refuses any other. - z : float, optional - Position along the waveguide at which everything is evaluated, m. - materials : MaterialSelection or None, optional - Resolve a named fill from optmat under this selection, as a run's - ``materials=`` does: its index, density and Kerr coefficient at - `wavelength`. ``None`` is ``MaterialSelection()``; a `Mixture` is taken as - it is. The mode is the caller's either way. - - Returns - ------- - SolitonParams - The numbers, with the mode. - - Raises - ------ - ToolsError - If a quantity is not positive, the shape is unknown, the mode has no - dispersion at the pump, a component of the fill has no Kerr data, the - selection names a carrier other than `wavelength`, or a pressure or a - temperature other than its own is given beside a `Mixture`. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import soliton_params - >>> mode = MarcatiliMode(125e-6, "He", 0.4, model="reduced", cladding=1.45) - >>> p = soliton_params(mode, "He", 0.4, energy=300e-6, fwhm=10e-15, - ... wavelength=800e-9) - >>> f"{p.soliton_order:.2f} {p.fission_length:.2f}" - '2.26 1.75' - """ - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - mixture = _resolve_fill(gas, pressure, temperature, materials) - omega = _omega(wavelength) - here = np.array([float(z)]) - n2, index = _fill_n2(mixture, wavelength, float(z)) - peak_power = pulse_peak_power(energy, fwhm, shape) - beta2 = float(base.beta2(mode, np.array([omega]), z)[0]) - gamma = nonlinear_coefficient(mode, n2, wavelength, z) - area = base.aeff(mode, z) - zdw = _zdw_near(mode, omega, float(z)) - shared = mixture.shared - return SolitonParams( - energy=float(energy), - fwhm=float(fwhm), - width=pulse_width(fwhm, shape), - omega=omega, - wavelength=float(wavelength), - gas="+".join(mixture.labels), - pressure=math.nan if shared is None else float(shared(here)[0]), - # _resolve_fill refused a temperature that varies along z. - temperature=cast("float", mixture.temperature), - shape=shape, - peak_power=peak_power, - beta2=beta2, - density=float(mixture.total_density(here)[0]), - index=index, - n2=n2, - gamma=gamma, - soliton_order=soliton_order(peak_power, fwhm, gamma, beta2, shape), - soliton_content=( - soliton_content(peak_power, fwhm, gamma, beta2, shape) - if beta2 < 0 - else None - ), - intensity=peak_power / area, - peak_intensity=_on_axis_intensity(mode, peak_power, float(z)), - critical_power=self_focusing_power(wavelength, index, n2), - dispersion_length=dispersion_length(fwhm, beta2, shape), - nonlinear_length=nonlinear_length(peak_power, gamma), - fission_length=fission_length(peak_power, fwhm, gamma, beta2, shape), - zdw=zdw, - loss_length=float(base.loss_length(mode, np.array([omega]), z)[0]), - effective_area=area, - mode=mode, - ) - - -def _capillary( - radius: float, - mixture: Mixture, - *, - n: int, - m: int, - kind: ModeKind, - model: MarcatiliModel, - loss: LossSpec, - cladding: GlassIndex | complex | None, -) -> MarcatiliMode: - """Build the HE, TE or TM capillary the capillary design tools report on. - - - ``radius``: core radius in m, > 0. - - ``m``: radial mode index, >= 1. - - ``cladding``: cladding index; ``None`` is fused silica. - """ - return MarcatiliMode( - positive(radius, "radius", ToolsError), - mixture=mixture, - n=n, - m=m, - kind=kind, - model=model, - loss=loss, - cladding=_default_cladding(cladding), - ) - - -def capillary_params( - radius: float, - gas: Fill, - pressure: float | None = None, - *, - energy: float, - fwhm: float, - wavelength: float, - shape: PulseShape = "sech", - temperature: float | None = None, - n: int = 1, - m: int = 1, - kind: ModeKind = "HE", - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, -) -> SolitonParams: - """Return the design numbers of a pulse in a gas-filled capillary. - - Builds the mode as :func:`solprop.driver.interface.prop_capillary` does for the same - arguments -- a :class:`~solprop.modes.capillary.MarcatiliMode` over the fill's - mixture -- and hands it to :func:`soliton_params`. - - Parameters - ---------- - radius : float - Core radius in m, > 0. - gas : Gas, str, Mapping or Mixture - The fill, as `prop_capillary` takes it. - pressure : float or None, optional - Total pressure in **bar**; ``None`` is one bar. For a gradient, evaluate at - the pressure of the position of interest. - energy : float - Pulse energy in J, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - wavelength : float - Pump wavelength in m, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape. - temperature : float or None, optional - Fill temperature in K; ``None`` is room temperature, or the mixture's own - beside a `Mixture`, which refuses any other. - n : int, optional - Azimuthal mode index. - m : int, optional - Radial mode index, >= 1. - kind : {'HE', 'TE', 'TM'}, optional - Mode family. - model : {'full', 'reduced'}, optional - Marcatili model. - loss : bool, float or callable, optional - Loss policy of the mode. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Build the capillary as `prop_capillary` does under the same ``materials=``: - the fill from optmat (index, density and Kerr coefficient at `wavelength`) - and ``cladding=None`` as optmat's recommended fused silica. ``None`` is - ``MaterialSelection()``. - - Returns - ------- - SolitonParams - The numbers, with the mode that was built. - - Examples - -------- - >>> from solprop.tools import capillary_params - >>> p = capillary_params(125e-6, "He", 0.4, energy=300e-6, fwhm=10e-15, - ... wavelength=800e-9, model="reduced", cladding=1.45) - >>> f"{p.gamma:.3e} {p.intensity:.3e}" - '4.884e-11 1.130e+18' - """ - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - mixture = _resolve_fill(gas, pressure, temperature, materials) - mode = _capillary( - radius, - mixture, - n=n, - m=m, - kind=kind, - model=model, - loss=loss, - cladding=cladding, - ) - return soliton_params( - mode, mixture, energy=energy, fwhm=fwhm, wavelength=wavelength, shape=shape - ) - - -def _area_per_order(shape: PulseShape) -> float: - """Return the pulse area over pi per unit conventional soliton order. - - - ``shape``: the pulse shape. - """ - return 1.0 if shape == "sech" else math.sqrt(2.0 / math.pi) - - -def _gaussian_order(eigenvalue: float) -> float: - """Return the conventional order whose Gaussian has a given largest eigenvalue. - - - ``eigenvalue``: target imaginary part of the largest Zakharov-Shabat eigenvalue, > - 0. - """ - threshold = 0.5 / _area_per_order("gauss") - - def residual(order: float) -> float: - """Return the largest eigenvalue at an order less the target.""" - # At and below the first threshold there is no bound state: the eigenvalue - # that appears there starts from zero. - if order <= threshold: - return -eigenvalue - return _gaussian_eigenvalue(order) - eigenvalue - - # N - eta of the Gaussian falls from sqrt(pi/8) = 0.627 at the first threshold - # towards 1/2 as N grows (0.579 at N = 1, 0.506 at N = 10, measured with - # _gaussian_eigenvalue), so the root lies between eta + 1/2 and eta + 0.627. The - # upper end carries a margin; brentq refuses a bracket that does not hold the root. - low = max(threshold, eigenvalue + 0.5) - high = eigenvalue + 0.7 - try: - return float(brentq(residual, low, high, xtol=1e-12, rtol=1e-11)) - except ValueError as error: - raise ToolsError( - f"no Gaussian soliton order between {low:.6g} and {high:.6g} has a largest " - f"eigenvalue of {eigenvalue:.6g}: {error}" - ) from error - - -def _target_order( - order: float | None, - count: int | None, - eigenvalue: float | None, - shape: PulseShape, -) -> float: - """Return the conventional soliton order that meets exactly one design target. - - - ``order``: a conventional soliton order, > 0. - - ``count``: a number of solitons, >= 1. - - ``eigenvalue``: the largest Zakharov-Shabat eigenvalue, > 0. - - ``shape``: the pulse shape. - """ - given = [ - name - for name, value in ( - ("soliton_order", order), - ("soliton_count", count), - ("largest_eigenvalue", eigenvalue), - ) - if value is not None - ] - if len(given) != 1: - raise ToolsError( - "give exactly one of soliton_order, soliton_count and largest_eigenvalue, " - f"got {', '.join(given) or 'none'}" - ) - if order is not None: - return positive(order, "soliton_order", ToolsError) - if count is not None: - if count < 1: - raise ToolsError(f"soliton_count must be at least 1, got {count}") - # The count is M = ceil(S - 1/2) for an area S (soliton_content), so the - # pulses with M solitons have S in (M - 1/2, M + 1/2]. The design takes the - # middle, S = M: the pure M-soliton for sech, the farthest from either - # threshold for a Gaussian. - return count / _area_per_order(shape) - eta = positive(cast("float", eigenvalue), "largest_eigenvalue", ToolsError) - return eta + 0.5 if shape == "sech" else _gaussian_order(eta) - - -def capillary_design( - radius: float, - gas: Gas | str, - *, - zdw_wavelength: float, - fwhm: float, - wavelength: float, - soliton_order: float | None = None, - soliton_count: int | None = None, - largest_eigenvalue: float | None = None, - shape: PulseShape = "sech", - temperature: float = ROOM_TEMPERATURE, - m: int = 1, - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, - pressure_max: float = 100.0, -) -> SolitonParams: - r"""Return the pressure and energy that give a capillary a soliton design. - - The inverse of :func:`capillary_params`: the pressure puts the zero-dispersion - point at `zdw_wavelength` (:func:`pressure_zdw`), and the energy then gives the - pulse one soliton target. At that pressure :math:`\beta_2` and :math:`\gamma` do - not depend on the energy and :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|`, so the - peak power is :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. Give exactly one - target: - - - `soliton_order`, the conventional :math:`N`; - - `soliton_count`, a number :math:`M` of solitons in the scattering content - (:func:`soliton_content`): the pulse area is set to :math:`M\pi`, the middle - of the band of areas with :math:`M` solitons, which is :math:`N = M` for - sech and :math:`N = M\sqrt{\pi/2}` for a Gaussian; - - `largest_eigenvalue`, the :math:`\eta` of the largest constituent, which - fixes its width :math:`\tau_0/(2\eta)`: :math:`N = \eta + 1/2` for sech, and - a root of the Gaussian scattering problem otherwise. - - Parameters - ---------- - radius : float - Core radius in m, > 0. - gas : Gas or str - A single built-in gas, as :func:`pressure_zdw` takes it. - zdw_wavelength : float - Wavelength at which the dispersion should vanish, m. - fwhm : float - Full width at half maximum of the power in s, > 0. - wavelength : float - Pump wavelength in m, > 0. - soliton_order : float or None, optional - Target conventional soliton order, > 0. - soliton_count : int or None, optional - Target number of solitons, >= 1. - largest_eigenvalue : float or None, optional - Target imaginary part of the largest Zakharov-Shabat eigenvalue, - dimensionless with time in units of the shape's natural width, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape, used for the width, the peak power and the content. - temperature : float, optional - Fill temperature in K. - m : int, optional - Radial index of the HE_1m mode. - model : {'full', 'reduced'}, optional - Marcatili model. - loss : bool, float or callable, optional - Loss policy of the mode in the returned numbers. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Design under this selection, as :func:`capillary_params` and - :func:`pressure_zdw` do. ``None`` is ``MaterialSelection()``. - pressure_max : float, optional - Upper end of the pressure bracket in **bar**. - - Returns - ------- - SolitonParams - The numbers at the designed pressure and energy, with the mode; the - pressure is ``.pressure`` (bar) and the energy ``.energy`` (J). - - Raises - ------ - ToolsError - If not exactly one target is given or it is out of range, no pressure in the - bracket gives the zero-dispersion point, or a content target is asked of a - pump in normal dispersion. - - Examples - -------- - >>> from solprop.tools import capillary_design - >>> p = capillary_design(125e-6, "Ar", zdw_wavelength=500e-9, fwhm=10e-15, - ... wavelength=800e-9, soliton_order=3.0, cladding=1.45) - >>> f"{p.pressure:.4f} bar {p.energy * 1e6:.1f} uJ N = {p.soliton_order:.3f}" - '0.0664 bar 100.5 uJ N = 3.000' - >>> g = capillary_design(125e-6, "Ar", zdw_wavelength=500e-9, fwhm=10e-15, - ... wavelength=800e-9, soliton_count=2, shape="gauss", - ... cladding=1.45) - >>> f"{g.soliton_order:.4f} {g.soliton_content.count}" - '2.5066 2' - """ - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - choice(shape, "shape", get_args(PulseShape), ToolsError) - order = _target_order(soliton_order, soliton_count, largest_eigenvalue, shape) - pressure = pressure_zdw( - radius, - gas, - zdw_wavelength, - pressure_max=pressure_max, - temperature=temperature, - m=m, - model=model, - cladding=cladding, - materials=materials, - ) - mixture = _resolve_fill(gas, pressure, temperature, materials) - mode = _capillary( - radius, - mixture, - n=1, - m=m, - kind="HE", - model=model, - loss=loss, - cladding=cladding, - ) - beta2 = float(base.beta2(mode, np.array([_omega(wavelength)]))[0]) - if soliton_order is None and beta2 >= 0.0: - raise ToolsError( - f"a soliton count or eigenvalue needs anomalous dispersion at the pump, " - f"but beta2 = {beta2:.4g} s^2/m at {wavelength * 1e9:.4g} nm with the " - f"zero-dispersion point at {zdw_wavelength * 1e9:.4g} nm" - ) - n2, _ = _fill_n2(mixture, wavelength, 0.0) - gamma = nonlinear_coefficient(mode, n2, wavelength) - peak_power = soliton_peak_power(order, fwhm, gamma, beta2, shape) - energy = pulse_energy(peak_power, fwhm, shape) - return soliton_params( - mode, mixture, energy=energy, fwhm=fwhm, wavelength=wavelength, shape=shape - ) - - -def gas_ratio( - gas_a: Gas | str, - gas_b: Gas | str, - wavelength: float, - *, - pressure: float = 1.0, - temperature: float = ROOM_TEMPERATURE, - materials: MaterialSelection | None = None, -) -> GasRatio: - """Return the ratios of bulk dispersion and Kerr response of two gases. - - The bulk (unguided) dispersion of each gas, not a mode's: it answers "how much - more pressure of B matches A", which is a property of the gases alone. - - Parameters - ---------- - gas_a : Gas or str - The numerator's species. - gas_b : Gas or str - The denominator's species. - wavelength : float - Wavelength in m at which the dispersions are compared, > 0. - pressure : float, optional - Pressure of both gases in **bar**, > 0. - temperature : float, optional - Temperature of both in K. - materials : MaterialSelection or None, optional - Take both gases from optmat under this selection, as a run's ``materials=`` - does: their index, their density and their Kerr coefficient at - `wavelength`; ``"air"`` is nitrogen and oxygen. ``None`` is - ``MaterialSelection()``. - - Returns - ------- - GasRatio - Named ``beta2`` and ``chi3`` ratios. - - Raises - ------ - ToolsError - If the selection names a carrier other than `wavelength`, or a gas has no - Kerr data under it. - - Examples - -------- - >>> from solprop.tools import gas_ratio - >>> ratio = gas_ratio("Ar", "He", 800e-9) - >>> f"{ratio.beta2:.1f} {ratio.chi3:.1f}" - '18.6 28.4' - """ - omega = _omega(wavelength) - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - dispersions = [] - susceptibilities = [] - for gas in (gas_a, gas_b): - fill = _resolve_fill( - gas, positive(pressure, "pressure", ToolsError), temperature, materials - ) - dispersions.append(_bulk_beta2(_BulkGas(fill), omega)) - susceptibilities.append(_fill_chi3(fill, wavelength)) - return GasRatio( - beta2=dispersions[0] / dispersions[1], - chi3=susceptibilities[0] / susceptibilities[1], - ) - - -@dataclass(frozen=True) -class _BulkGas: - """A uniform gas as an index model, for bulk dispersion. - - Attributes - ---------- - fill : Mixture - The gas resolved under a material selection, whose index is taken. - """ - - fill: Mixture - - def excess_beta( - self, omega: Float[np.ndarray, " n_omega"] - ) -> Float[np.ndarray, " n_omega"]: - """Return the gas's part of the bulk propagation constant. - - The vacuum's ``omega / c`` has no second derivative, and leaving it out of a - difference quotient removes its rounding, which at a gas's ``n - 1`` of 1e-5 is - the larger part of the quotient. - - - ``omega``: angular frequencies in rad/s. - - Returns: ``omega (n - 1) / c`` in 1/m, with ``n - 1`` formed without - cancellation. - """ - chi = self.fill.susceptibility(omega, np.zeros(1))[0] - excess = chi / (1.0 + np.sqrt(1.0 + chi)) - return np.asarray(omega * np.real(excess) / C) - - -def _bulk_beta2(bulk: _BulkGas, omega: float) -> float: - """Return the bulk group-velocity dispersion by a Richardson central difference. - - - ``omega``: angular frequency in rad/s. - - Returns: ``d^2 beta / d omega^2`` in s^2/m. - """ - # A second difference of the gas's part of beta: only a ratio of two such numbers is - # used, so the few parts in 1e5 this reaches at the step modes.base chose are ample. - step = base.BETA2_RELATIVE_STEP * omega - - def second(h: float) -> float: - """Return the three-point second difference at step ``h``. - - - ``h``: step in rad/s. - - Returns: the difference quotient in s^2/m. - """ - values = bulk.excess_beta(np.array([omega - h, omega, omega + h])) - return float(values[0] - 2.0 * values[1] + values[2]) / h**2 - - return (4.0 * second(step / 2.0) - second(step)) / 3.0 - - -# -------------------------------------------------------------------- phase matching - - -@dataclass(frozen=True) -class _PhasePulse: - """Validate the pulse declaration before a phase-matching search.""" - - peak_power: float | None - gamma: float | None - fwhm: float | None - shape: PulseShape - nonlinear_phase: NonlinearPhase - - def __post_init__(self) -> None: - """Refuse incomplete or contradictory phase declarations.""" - choice(self.shape, "shape", get_args(PulseShape), ToolsError) - choice( - self.nonlinear_phase, - "nonlinear_phase", - get_args(NonlinearPhase), - ToolsError, - ) - if (self.peak_power is None) != (self.gamma is None): - raise ToolsError( - "the nonlinear term needs both peak_power and gamma; " - "give both or neither" - ) - if self.nonlinear_phase == "linear" and ( - self.peak_power is not None or self.fwhm is not None - ): - raise ToolsError( - "nonlinear_phase='linear' requires omitting pulse power, gamma and fwhm" - ) - if self.peak_power is None: - if self.fwhm is not None: - raise ToolsError("fwhm requires peak_power and gamma") - return - positive(self.peak_power, "peak_power", ToolsError) - assert self.gamma is not None # The paired declaration was validated above. - positive(self.gamma, "gamma", ToolsError) - if self.nonlinear_phase == "largest": - if self.fwhm is None: - raise ToolsError( - "the largest constituent needs fwhm; supply it or select " - "nonlinear_phase='supplied_power'" - ) - positive(self.fwhm, "fwhm", ToolsError) - elif self.fwhm is not None: - raise ToolsError( - "nonlinear_phase='supplied_power' does not use fwhm; omit it" - ) - - def wavenumber(self, mode: Mode, omega: float, z: float) -> float: - """Calculate the nonlinear phase in the soliton mode. - - - ``omega``: pump angular frequency in rad/s. - - ``z``: position in m. - - Returns: nonlinear wavenumber in 1/m. - """ - if self.peak_power is None or self.gamma is None: - return 0.0 - if self.fwhm is None: - return 0.5 * self.gamma * self.peak_power - dispersion = float(base.beta2(mode, np.array([omega]), z)[0]) - content = soliton_content( - self.peak_power, self.fwhm, self.gamma, dispersion, self.shape - ) - if content.largest_nonlinear_wavenumber is None: - raise ToolsError( - "the pulse has no discrete soliton for " - "largest-constituent phase matching" - ) - return content.largest_nonlinear_wavenumber - - -@dataclass(frozen=True) -class _PhaseMismatch: - """Evaluate a mismatch with the pump and nonlinear phase prepared once.""" - - mode: Mode - omega0: float - beta0: float - slope: float - nonlinear: float - z: float - - def __call__( - self, omega: Float[np.ndarray, " n_omega"] - ) -> Float[np.ndarray, " n_omega"]: - """Evaluate the mismatch at trial frequencies. - - - ``omega``: angular frequencies in rad/s. - - Returns: mismatch in 1/m. - """ - return np.asarray( - base.beta(self.mode, omega, self.z) - - self.beta0 - - self.slope * (omega - self.omega0) - - self.nonlinear - ) - - -def _prepare_mismatch( - mode: Mode, - wavelength: float, - soliton_mode: Mode | None, - z: float, - pulse: _PhasePulse, -) -> _PhaseMismatch: - """Prepare a phase-matching calculation at one position. - - - ``wavelength``: pump wavelength in m. - - ``soliton_mode``: pump mode, or None to use mode. - - ``z``: position in m. - """ - soliton = mode if soliton_mode is None else soliton_mode - omega0 = _omega(wavelength) - pump = np.array([omega0]) - return _PhaseMismatch( - mode, - omega0, - float(base.beta(soliton, pump, z)[0]), - float(base.beta1(soliton, pump, z)[0]), - pulse.wavenumber(soliton, omega0, z), - z, - ) - - -def phase_mismatch( - mode: Mode, - omega: Float[np.ndarray, " n_omega"], - wavelength: float, - *, - soliton_mode: Mode | None = None, - z: float = 0.0, - peak_power: float | None = None, - gamma: float | None = None, - fwhm: float | None = None, - shape: PulseShape = "sech", - nonlinear_phase: NonlinearPhase = "largest", -) -> Float[np.ndarray, " n_omega"]: - r"""Return the phase mismatch between a soliton and a dispersive wave. - - .. math:: - \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) - - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} . - - Parameters - ---------- - mode : Mode - The mode the dispersive wave is emitted into. - omega : numpy.ndarray - Angular frequencies in rad/s, shape ``(n_omega,)``. - wavelength : float - Wavelength of the soliton in m, > 0. - soliton_mode : Mode or None, optional - The mode the soliton travels in; ``None`` is `mode`. A different one gives - intermodal phase matching. - z : float, optional - Position along the waveguide, m. - peak_power : float or None, optional - Input peak power in W. With `gamma` and `fwhm`, adds the soliton's nonlinear - wavenumber; both ``None`` (the default) gives the linear condition. - gamma : float or None, optional - Nonlinear coefficient of the soliton's mode in rad/(W m) - (:func:`nonlinear_coefficient`). - fwhm : float or None, optional - Input power FWHM in s, positive; required with power for the default phase. - shape : {'sech', 'gauss'}, optional - Unchirped input shape for the largest constituent. - nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional - Default uses the largest constituent. Supplied power uses gamma P0/2 and - requires omitting fwhm. Linear requires omitting all pulse numbers. - Without pulse numbers the default is also linear. - - Returns - ------- - numpy.ndarray - Mismatch in 1/m, shape ``(n_omega,)``. It vanishes at the pump in the linear - case, with zero slope there. - - Raises - ------ - ToolsError - If pulse arguments are incomplete or contradictory, or the largest - constituent does not exist in anomalous dispersion. - - Notes - ----- - The default nonlinear term is gamma times the largest constituent's power / 2 - (:func:`soliton_content`). The supplied-power option treats the quoted power as - a fundamental soliton, advancing as exp(i gamma P0 z / 2) (Akhmediev and - Karlsson, Phys. Rev. A 51, 2602 (1995)). Both are local estimates; loss, - higher-order dispersion, Raman, chirp and plasma evolution are not included. - - Examples - -------- - >>> import numpy as np - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import phase_mismatch - >>> mode = MarcatiliMode(125e-6, "He", 1.0) - >>> pump = 2 * np.pi * 299792458.0 / 800e-9 - >>> abs(float(phase_mismatch(mode, np.array([pump]), 800e-9)[0])) < 1e-6 - True - """ - pulse = _PhasePulse(peak_power, gamma, fwhm, shape, nonlinear_phase) - prepared = _prepare_mismatch(mode, wavelength, soliton_mode, z, pulse) - return prepared(np.asarray(omega, dtype=float)) - - -def lambda_rdw_all( - mode: Mode, - wavelength: float, - *, - soliton_mode: Mode | None = None, - z: float = 0.0, - peak_power: float | None = None, - gamma: float | None = None, - fwhm: float | None = None, - shape: PulseShape = "sech", - nonlinear_phase: NonlinearPhase = "largest", - wavelength_limits: tuple[float, float] | None = None, -) -> tuple[float, ...]: - """Return every phase-matched wavelength in a window, shortest first. - - Parameters - ---------- - mode : Mode - The mode the dispersive wave is emitted into. - wavelength : float - Wavelength of the soliton in m, > 0. - soliton_mode : Mode or None, optional - The soliton's mode; ``None`` is `mode`. - z : float, optional - Position along the waveguide, m. - peak_power : float or None, optional - Peak power in W for the nonlinear term; see :func:`phase_mismatch`. - gamma : float or None, optional - Nonlinear coefficient in rad/(W m) for the nonlinear term. - fwhm : float or None, optional - Input power FWHM in s, positive; required with power for the default phase. - shape : {'sech', 'gauss'}, optional - Unchirped input shape for the largest constituent. - nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional - Default uses the largest constituent. Supplied power uses gamma P0/2 and - requires omitting fwhm. Linear requires omitting all pulse numbers. - Without pulse numbers the default is also linear. - wavelength_limits : tuple of float or None, optional - Shortest and longest wavelength searched, m. ``None`` is 100 nm to 0.9 of the - pump wavelength, which keeps the double root at the pump out. - - Returns - ------- - tuple of float - The roots in m, ascending; empty when there is none. The mismatch is sampled - on 2048 frequencies across the window, each sign change is refined with - Brent's method, and roots closer than one sample are not separated. A sign - change through a resonance of the guide, where the mismatch has a pole or is - not a number, is not a root and is skipped. - - Raises - ------ - ToolsError - If the window is not positive and ordered. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import lambda_rdw_all - >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) - >>> [f"{root * 1e9:.0f}" for root in lambda_rdw_all(mode, 800e-9)] - ['200'] - """ - pulse = _PhasePulse(peak_power, gamma, fwhm, shape, nonlinear_phase) - prepared = _prepare_mismatch(mode, wavelength, soliton_mode, z, pulse) - limits = ( - (100e-9, 0.9 * wavelength) if wavelength_limits is None else wavelength_limits - ) - shortest, longest = limits - if not 0.0 < shortest < longest: - raise ToolsError( - f"the wavelength limits must be positive and ordered: {limits}" - ) - grid = np.linspace( - 2.0 * math.pi * C / longest, 2.0 * math.pi * C / shortest, _MISMATCH_SAMPLES - ) - - def _mismatch(value: float) -> float: - """Return the mismatch at one frequency, for the root finder. - - - ``value``: angular frequency in rad/s. - - Returns: mismatch in 1/m. - """ - return float(prepared(np.array([value]))[0]) - - with _probing(): - sampled = prepared(grid) - if not np.all(np.isfinite(sampled)): - # A sample that is not a number compares false with everything, so it - # would delete any root beside it without a word. - missing = int(np.sum(~np.isfinite(sampled))) - raise ToolsError( - f"the phase mismatch is not finite at {missing} of {sampled.size} " - f"samples in {shortest * 1e9:.4g}-{longest * 1e9:.4g} nm: the index " - "model does not cover the window" - ) - signs = np.sign(sampled) - found = [float(grid[i]) for i in np.flatnonzero(signs == 0.0)] - for i in np.flatnonzero(signs[:-1] * signs[1:] < 0.0): - try: - # The refinement may land on a resonance exactly, where a model may - # divide by zero: such a point is no root, and is told apart below. - with np.errstate(divide="ignore", invalid="ignore"): - root = float(brentq(_mismatch, float(grid[i]), float(grid[i + 1]))) - except ValueError: - # The index model is not a number inside the bracket: a resonance of - # the guide (an antiresonant fibre's), not a phase-matched wavelength. - continue - with np.errstate(divide="ignore", invalid="ignore"): - residual = _mismatch(root) - if not abs(residual) <= min(abs(sampled[i]), abs(sampled[i + 1])): - # Refined onto a pole, through which the sign changed: no root. - continue - found.append(root) - if found: - # Outside the probing context, so a root beyond the index model's range warns. - base.beta(mode, np.array(sorted(found)), z) - return tuple(sorted(2.0 * math.pi * C / root for root in found)) - - -def lambda_rdw( - mode: Mode, - wavelength: float, - *, - soliton_mode: Mode | None = None, - z: float = 0.0, - peak_power: float | None = None, - gamma: float | None = None, - fwhm: float | None = None, - shape: PulseShape = "sech", - nonlinear_phase: NonlinearPhase = "largest", - wavelength_limits: tuple[float, float] | None = None, -) -> float: - """Return the phase-matched dispersive-wave wavelength nearest the pump. - - Parameters - ---------- - mode : Mode - The mode the dispersive wave is emitted into. - wavelength : float - Wavelength of the soliton in m, > 0. - soliton_mode : Mode or None, optional - The soliton's mode; ``None`` is `mode`. - z : float, optional - Position along the waveguide, m. - peak_power : float or None, optional - Peak power in W for the nonlinear term; see :func:`phase_mismatch`. - gamma : float or None, optional - Nonlinear coefficient in rad/(W m) for the nonlinear term. - fwhm : float or None, optional - Input power FWHM in s, positive; required with power for the default phase. - shape : {'sech', 'gauss'}, optional - Unchirped input shape for the largest constituent. - nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional - Default uses the largest constituent. Supplied power uses gamma P0/2 and - requires omitting fwhm. Linear requires omitting all pulse numbers. - Without pulse numbers the default is also linear. - wavelength_limits : tuple of float or None, optional - Window searched, m; ``None`` is 100 nm to 0.9 of the pump wavelength. - - Returns - ------- - float - The longest root inside the window in m, which with the default window is the - short-wavelength dispersive wave nearest the pump. :func:`lambda_rdw_all` - returns all of them. - - Raises - ------ - ToolsError - If the window holds no root, rather than returning a missing value that would - propagate silently. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import lambda_rdw - >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) - >>> f"{lambda_rdw(mode, 800e-9) * 1e9:.1f}" - '200.1' - """ - roots = lambda_rdw_all( - mode, - wavelength, - soliton_mode=soliton_mode, - z=z, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, - nonlinear_phase=nonlinear_phase, - wavelength_limits=wavelength_limits, - ) - if not roots: - limits = ( - (100e-9, 0.9 * wavelength) - if wavelength_limits is None - else wavelength_limits - ) - ends = phase_mismatch( - mode, - 2.0 * math.pi * C / np.array(limits[::-1]), - wavelength, - soliton_mode=soliton_mode, - z=z, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, - nonlinear_phase=nonlinear_phase, - ) - raise ToolsError( - f"no phase-matched wavelength in {limits[0] * 1e9:.4g}-" - f"{limits[1] * 1e9:.4g} nm: the mismatch is {ends[1]:.4g} 1/m at the short " - f"end and {ends[0]:.4g} 1/m at the long end and never changes sign" - ) - return roots[-1] - - -def _pressure_root( - residual: Callable[[float], float], pressure_max: float, what: str -) -> float: - """Return the pressure at which a function of pressure vanishes. - - - ``residual``: a function of the pressure in bar. - - ``pressure_max``: upper end of the bracket in bar. - - Returns: the root in bar. - """ - top = positive(pressure_max, "pressure_max", ToolsError) - if top <= _PRESSURE_FLOOR: - raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") - with _probing(): - low, high = residual(_PRESSURE_FLOOR), residual(top) - if not (math.isfinite(low) and math.isfinite(high)): - raise ToolsError( - f"the residual for {what} is not finite at the ends of the pressure " - f"bracket ({low!r}, {high!r}): the index model does not reach that far" - ) - if low * high > 0.0: - raise ToolsError( - f"no pressure between {_PRESSURE_FLOOR} and {top:.4g} bar gives {what}: " - f"the residual is {low:.4g} at the low end and {high:.4g} at the high end" - ) - with _probing(): - root = float(brentq(residual, _PRESSURE_FLOOR, top)) - # Outside the probing context, so the answer's own warnings are raised. - residual(root) - return root - - -def _design_capillary( - radius: float, - gas: Gas | str, - *, - temperature: float, - m: int, - model: MarcatiliModel, - cladding: GlassIndex | complex | None, - lattice: TubeLattice | None, - materials: MaterialSelection | None, -) -> MarcatiliMode: - """Build the single-fill capillary a pressure search re-evaluates. - - The mode `prop_capillary` builds with the same ``materials=``: the gas (or - ``"air"``) as an optmat fill at one bar and ``cladding=None`` as optmat's - recommended silica. - - - ``radius``: core radius in m, > 0. - - ``temperature``: fill temperature in K. - - ``cladding``: cladding index; ``None`` is fused silica. - - ``lattice``: tube geometry for a VincettiMode; None builds a Marcatili capillary. - """ - bore = positive(radius, "radius", ToolsError) - wall = _default_cladding(cladding) - # The fill is an optmat mixture, which carries its own pressure and temperature. - mixture = _resolve_fill(gas, 1.0, temperature, materials) - if lattice is None: - return MarcatiliMode( - bore, None, 0.0, m=m, model=model, cladding=wall, mixture=mixture - ) - return VincettiMode( - bore, - None, - 0.0, - m=m, - model=model, - cladding=wall, - mixture=mixture, - lattice=lattice, - ) - - -def _capillary_at(mode: MarcatiliMode, pressure: float) -> MarcatiliMode: - """Return a single-fill capillary re-evaluated at another pressure. - - The mode is built once and frozen at each density through its parameterised form, - so a root finder costs an equation-of-state call and an index evaluation per step - rather than a mode construction. A selected fill takes its density from its - species' own equation of state (optmat's); a selected air, two populations at - one shared pressure, is frozen at that pressure. - - - ``pressure``: pressure in bar. - """ - # pressure_rdw and pressure_zdw build this mode at their one scalar temperature. - temperature = cast("float", mode.temperature) - # `_design_capillary` always builds the mode on an optmat fill. - fill = cast("Mixture", mode.mixture) - if len(fill.components) > 1: - return mode.at_parameters({"pressure": float(pressure)}) - state = next(iter(fill.components)).species.equation_of_state - rho = float(state.density(float(pressure), temperature)) - return mode.at_parameters({"density": rho}) - - -def _search_grid( - low: float, high: float, samples: int = _SEARCH_SAMPLES -) -> Float[np.ndarray, " n_samples"]: - """Return the values a sampled root search evaluates its residual at. - - - Returns: `samples` linear values, and as many logarithmic ones where ``low > 0``, - merged and ascending; both ends are among them. - """ - # The logarithmic grid resolves the low end of a range spanning decades (a - # pressure from 1e-6 bar); the linear grid resolves structure at its high end. - # Pairs of roots, or admissible intervals, narrower than a sample are not - # guaranteed: narrowing the range resolves them. - linear = np.linspace(low, high, samples) - if low <= 0.0: - return linear - return np.unique(np.concatenate((np.geomspace(low, high, samples), linear))) - - -@dataclass(frozen=True) -class _Sampled: - """The outcome of a sampled root search. - - Attributes - ---------- - roots : tuple of float - The roots, ascending. - values : tuple of float - The sampled values of the parameter, ascending. - residuals : tuple of float or None - The residual at each sample; None where the target is undefined. - """ - - roots: tuple[float, ...] - values: tuple[float, ...] - residuals: tuple[float | None, ...] - - -def _sampled_roots( - residual: Callable[[float], float | None], - limits: tuple[float, float], - *, - what: str, - narrow: str, -) -> _Sampled: - """Find every root of a residual over a range, without crossing a pole. - - The residual is sampled on :func:`_search_grid`; each sign change between two - samples where it is defined is refined with Brent's method, and the refined - point is a root only if the residual there is below :data:`_ROOT_RESIDUAL` of - its larger value at the bracket's ends. A sample that is exactly zero is a root. - - - ``residual``: a function of the parameter returning a finite float, or None where - the target is undefined (an inadmissible state, such as no soliton). - """ - low, high = limits - grid = _search_grid(low, high) - # Brent's method re-evaluates the ends of its bracket and the root it returns, - # which are samples already: one evaluation per distinct value. - seen: dict[float, float | None] = {} - - def checked(value: float) -> float | None: - """Evaluate the residual once per value, refusing a value that is not finite. - - - Returns: finite residual, or None where the target is undefined. - """ - if value not in seen: - result = residual(value) - if result is not None and not math.isfinite(result): - raise ToolsError(f"{what} is not finite at {value:g}") - seen[value] = result - return seen[value] - - def refine(value: float) -> float: - """Evaluate the residual inside one bracket, where it must be defined.""" - result = checked(value) - if result is None: - # The only undefined state a target has is the largest constituent's - # absence, below the first soliton threshold or in normal dispersion. - raise ToolsError( - f"the refinement of {what} crossed a no-soliton region at " - f"{value:g}, between two samples outside it; narrow {narrow}" - ) - return result - - with _probing(): - sampled = [checked(float(value)) for value in grid] - roots = [ - float(value) - for value, result in zip(grid, sampled, strict=True) - if result == 0.0 - ] - for i, (left, right) in enumerate(pairwise(sampled)): - if left is None or right is None or left * right >= 0.0: - continue - start, stop = float(grid[i]), float(grid[i + 1]) - root = float( - brentq( - refine, - start, - stop, - xtol=_ROOT_TOLERANCE * max(abs(start), abs(stop)), - ) - ) - if abs(refine(root)) <= _ROOT_RESIDUAL * max(abs(left), abs(right)): - roots.append(root) - else: - logger.debug( - "discarded the sign change of %s at %g as a pole", what, root - ) - return _Sampled( - tuple(sorted(roots)), - tuple(float(value) for value in grid), - tuple(sampled), - ) - - -def _rdw_pressure_root( - residual: Callable[[float], float | None], limits: tuple[float, float] -) -> float: - """Find a unique pressure root without crossing a sampled inadmissible region. - - - ``residual``: mismatch in 1/m, or None only where no focusing discrete soliton - exists. - - ``limits``: positive ordered pressure bracket in bar. - - Returns: unique pressure root in bar. - """ - low, high = limits - if not (math.isfinite(low) and math.isfinite(high) and 0 < low < high): - raise ToolsError("pressure_limits must be finite, positive and ordered") - roots = _sampled_roots( - residual, limits, what="the RDW pressure residual", narrow="pressure_limits" - ).roots - if not roots: - raise ToolsError( - f"no pressure between {low:g} and {high:g} bar gives an admissible " - "RDW root; narrow pressure_limits if needed" - ) - root, *others = roots - if others: - found = ", ".join(f"{value:.4g}" for value in roots) - raise ToolsError( - f"ambiguous RDW pressure: {len(roots)} roots, at {found} bar; " - "choose one with pressure_limits" - ) - # Re-evaluate the accepted answer outside the probing warning filter. - residual(root) - return root - - -def pressure_rdw( - radius: float, - gas: Gas | str, - target_wavelength: float, - wavelength: float, - *, - pressure_max: float = 100.0, - peak_power: float | None = None, - fwhm: float | None = None, - shape: PulseShape = "sech", - nonlinear_phase: NonlinearPhase = "largest", - lattice: TubeLattice | None = None, - pressure_limits: tuple[float, float] | None = None, - temperature: float = ROOM_TEMPERATURE, - m: int = 1, - model: MarcatiliModel = "full", - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, -) -> float: - """Return the pressure that phase matches a dispersive wave at a target wavelength. - - Parameters - ---------- - radius : float - Core radius in m, > 0. - gas : Gas or str - A single built-in gas. - target_wavelength : float - Wavelength the dispersive wave should have, m. - wavelength : float - Pump wavelength in m. - pressure_max : float, optional - Upper end of the pressure bracket in **bar**; the lower end is 1e-6 bar. - peak_power : float or None, optional - Peak power in W. When given, the soliton's nonlinear wavenumber is included - and its nonlinear coefficient follows the pressure. - fwhm : float or None, optional - Positive input power FWHM in s, required with power for the default phase. - shape : {'sech', 'gauss'}, optional - Unchirped input shape. - nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional - Largest constituent by default; supplied power preserves gamma P0/2 - without fwhm. Linear requires omitting pulse numbers. - lattice : TubeLattice or None, optional - Tube geometry for VincettiMode; None builds a Marcatili capillary. - pressure_limits : tuple of float or None, optional - Positive ordered search range in bar, within (1e-6, pressure_max). - None uses that whole range. Searches 129 logarithmic and 129 linear - samples, refining admissible sign changes; sub-grid root pairs or valid - intervals can be missed. Narrow the range to resolve them. - temperature : float, optional - Fill temperature in K. - m : int, optional - Radial index of the HE_1m mode. - model : {'full', 'reduced'}, optional - Marcatili model. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Design the capillary `prop_capillary` builds under the same - ``materials=``: the gas from optmat (index, density, and the Kerr - coefficient at `wavelength` for the nonlinear phase) and ``cladding=None`` - as optmat's recommended fused silica. ``None`` is - ``MaterialSelection()``. - - Returns - ------- - float - Pressure in bar. - - Raises - ------ - ToolsError - If no pressure in the bracket matches, or the selection names a carrier - other than `wavelength`. - - Examples - -------- - >>> from solprop.tools import pressure_rdw - >>> f"{pressure_rdw(125e-6, 'He', 199.75e-9, 800e-9, cladding=1.45):.2f}" - '1.00' - """ - # Validate before scanning; the nonlinear coefficient follows the pressure. - design = RDW( - wavelength, - target_wavelength, - peak_power=peak_power, - fwhm=fwhm, - shape=shape, - nonlinear_phase=nonlinear_phase, - ) - top = positive(pressure_max, "pressure_max", ToolsError) - if top <= _PRESSURE_FLOOR: - raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") - limits = (_PRESSURE_FLOOR, top) if pressure_limits is None else pressure_limits - if limits[0] < _PRESSURE_FLOOR or limits[1] > top: - raise ToolsError("pressure_limits must lie within (1e-6, pressure_max) bar") - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - base_mode = _design_capillary( - radius, - gas, - temperature=temperature, - m=m, - model=model, - cladding=cladding, - lattice=lattice, - materials=materials, - ) - - def _residual(pressure: float) -> float | None: - """Return the mismatch at the target for one pressure. - - - ``pressure``: pressure in bar. - - Returns: mismatch in 1/m, or None when the constituent does not exist. - """ - mode = _capillary_at(base_mode, pressure) - gamma = None - if peak_power is not None: - assert mode.mixture is not None - index = _fill_n2(mode.mixture, wavelength)[0] - gamma = nonlinear_coefficient(mode, index, wavelength) - return _rdw_residual(design, mode, gamma, 0.0) - - return _rdw_pressure_root(_residual, limits) - - -def pressure_zdw( - radius: float, - gas: Gas | str, - zdw_wavelength: float, - *, - pressure_max: float = 100.0, - temperature: float = ROOM_TEMPERATURE, - m: int = 1, - model: MarcatiliModel = "full", - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, -) -> float: - """Return the pressure that puts the zero-dispersion point at a wavelength. - - Parameters - ---------- - radius : float - Core radius in m, > 0. - gas : Gas or str - A single built-in gas. - zdw_wavelength : float - Wavelength at which the dispersion should vanish, m. - pressure_max : float, optional - Upper end of the pressure bracket in **bar**; the lower end is 1e-6 bar. - temperature : float, optional - Fill temperature in K. - m : int, optional - Radial index of the HE_1m mode. - model : {'full', 'reduced'}, optional - Marcatili model. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Design the capillary `prop_capillary` builds under the same - ``materials=``: the gas's index and density from optmat and - ``cladding=None`` as optmat's recommended fused silica. ``None`` is - ``MaterialSelection()``. - - Returns - ------- - float - Pressure in bar. - - Raises - ------ - ToolsError - If no pressure in the bracket does. - - Examples - -------- - >>> from solprop.tools import pressure_zdw - >>> f"{pressure_zdw(125e-6, 'Ar', 500e-9, cladding=1.45):.3f}" - '0.066' - """ - materials = MaterialSelection() if materials is None else materials - base_mode = _design_capillary( - radius, - gas, - temperature=temperature, - m=m, - model=model, - cladding=cladding, - lattice=None, - materials=materials, - ) - omega = np.array([_omega(zdw_wavelength)]) - - def _residual(pressure: float) -> float: - """Return the dispersion at the wavelength for one pressure. - - - ``pressure``: pressure in bar. - - Returns: ``beta_2`` in s^2/m, scaled to order one. - """ - mode = _capillary_at(base_mode, pressure) - # Scaled so that brentq's absolute tolerance on the residual is meaningless - # and its tolerance on the pressure decides, as intended. - return float(base.beta2(mode, omega)[0]) * 1e30 - - return _pressure_root( - _residual, - pressure_max, - f"a zero-dispersion wavelength of {zdw_wavelength * 1e9:.4g} nm", - ) - - -# ------------------------------------------------------------------- design solvers - - -@dataclass(frozen=True) -class ZDW: - r"""Target a zero-dispersion wavelength: :math:`\beta_2 = 0` at a wavelength. - - The residual :func:`solve_parameter` brackets is :math:`\beta_2` of the trial mode - at `wavelength`, in s^2/m. - - Attributes - ---------- - wavelength : float - Wavelength at which the dispersion should vanish, m, > 0. - - Raises - ------ - ToolsError - If the wavelength is not finite and positive. - - Examples - -------- - >>> from solprop.tools import ZDW - >>> ZDW(500e-9) - ZDW(wavelength=5e-07) - """ - - wavelength: float - - def __post_init__(self) -> None: - """Refuse a wavelength that is not finite and positive. - - Raises - ------ - ToolsError - If the wavelength is not finite and positive. - """ - positive(self.wavelength, "wavelength", ToolsError) - - -@dataclass(frozen=True) -class RDW: - """Target a resonant dispersive wave phase matched at a wavelength. - - The residual :func:`solve_parameter` brackets is :func:`phase_mismatch` of the - trial mode at `rdw`, in 1/m, exactly as :func:`pressure_rdw` evaluates it: with - `peak_power` the soliton's nonlinear wavenumber is included, with the trial - mode's own nonlinear coefficient, and with the largest constituent (`fwhm` - given) a trial value in normal dispersion or below the first soliton threshold - has no residual and cannot bracket a root. - - Attributes - ---------- - pump : float - Wavelength of the soliton, m, > 0. - rdw : float - Wavelength the dispersive wave should have, m, > 0 and not the pump's. - peak_power : float or None - Input peak power in W for the nonlinear term; ``None`` is the linear - condition. With it, the builder returns ``(mode, gamma)``. - fwhm : float or None - Input power FWHM in s; required with `peak_power` for the default phase. - shape : {'sech', 'gauss'} - Unchirped input shape for the largest constituent. - nonlinear_phase : {'largest', 'supplied_power', 'linear'} - The nonlinear phase model, as :func:`phase_mismatch` takes it. - - Raises - ------ - ToolsError - If a wavelength is not positive, the two are equal, or the pulse numbers - are incomplete or contradictory for the phase model. - - Examples - -------- - >>> from solprop.tools import RDW - >>> RDW(800e-9, 200e-9, peak_power=2e9, fwhm=10e-15).nonlinear_phase - 'largest' - """ - - pump: float - rdw: float - peak_power: float | None = None - fwhm: float | None = None - shape: PulseShape = "sech" - nonlinear_phase: NonlinearPhase = "largest" - - def __post_init__(self) -> None: - """Refuse wavelengths or pulse numbers the phase matching does not define. - - Raises - ------ - ToolsError - If a wavelength is not positive, the two are equal, or the pulse - numbers are incomplete or contradictory. - """ - # A placeholder gamma: the real one comes with each trial mode. - _PhasePulse( - self.peak_power, - None if self.peak_power is None else 1.0, - self.fwhm, - self.shape, - self.nonlinear_phase, - ) - positive(self.pump, "pump", ToolsError) - positive(self.rdw, "rdw", ToolsError) - if self.rdw == self.pump: - # The linear mismatch and its slope vanish at the pump for every mode. - raise ToolsError( - f"the dispersive wave cannot be at the pump wavelength, {self.pump:g} m" - ) - - -@dataclass(frozen=True) -class SolitonOrder: - """Target a conventional soliton order for a fixed pulse. - - The residual :func:`solve_parameter` brackets is :func:`soliton_order` of the - pulse in the trial mode, with the trial mode's own nonlinear coefficient, minus - `order`: a guide or fill parameter is solved for, the pulse is held. The order - is the conventional measure on either side of a zero-dispersion point, so a - search across one may find a root on each side. The opposite question, the - pulse that has an order on a given mode, is closed form: - :func:`soliton_peak_power` and :func:`pulse_energy`. - - Attributes - ---------- - order : float - Conventional soliton order, > 0. - pulse : GaussianPulse or SechPulse - The pulse: its wavelength is the pump, its FWHM and its energy or peak power - are held, and its class sets the shape. An ordinary Gaussian (``m = 1``) - with no spectral phase beyond a carrier-envelope phase and a delay. - - Raises - ------ - ToolsError - If the order is not positive, the Gaussian is super-Gaussian, or the pulse - is chirped. - - Examples - -------- - >>> from solprop.pulses import SechPulse - >>> from solprop.tools import SolitonOrder - >>> target = SolitonOrder(3.0, SechPulse(wavelength=800e-9, fwhm=10e-15, - ... energy=100e-6)) - >>> f"{target.peak_power / 1e9:.3f} GW" - '8.814 GW' - """ - - order: float - pulse: GaussianPulse | SechPulse - - def __post_init__(self) -> None: - """Refuse an order or a pulse the design numbers are not defined for. - - Raises - ------ - ToolsError - If the order is not positive, the Gaussian is super-Gaussian, or the - pulse is chirped. - """ - positive(self.order, "order", ToolsError) - if isinstance(self.pulse, GaussianPulse) and self.pulse.m != 1: - raise ToolsError( - "the soliton order is defined for sech and Gaussian pulses, and this " - f"is a super-Gaussian of order m={self.pulse.m}" - ) - if np.any(np.asarray(self.pulse.phase, dtype=float)[2:] != 0.0): - # The design numbers take the width and peak power of an unchirped pulse. - raise ToolsError( - "the soliton order is defined for an unchirped pulse; this one has " - "spectral phase beyond the carrier-envelope phase and the delay" - ) - - @property - def shape(self) -> PulseShape: # numpydoc ignore=RT01 - """Pulse shape the design numbers use. - - ``"gauss"`` for a `GaussianPulse`, ``"sech"`` for a `SechPulse`. - """ - return "gauss" if isinstance(self.pulse, GaussianPulse) else "sech" - - @property - def fwhm(self) -> float: # numpydoc ignore=RT01 - """The pulse's full width at half maximum of the power, s. - - A `SechPulse` given its natural width converts it. - """ - pulse = self.pulse - if isinstance(pulse, GaussianPulse): - return pulse.fwhm - # SechPulse holds exactly one of the two widths (its own validation). - return ( - pulse.fwhm - if pulse.fwhm is not None - else cast("float", pulse.width) * _SECH_WIDTH - ) - - @property - def peak_power(self) -> float: # numpydoc ignore=RT01 - """The pulse's peak power, W. - - The pulse's own, or that of its energy (:func:`pulse_peak_power`). - """ - if self.pulse.peak_power is not None: - return self.pulse.peak_power - # ShapedPulse holds exactly one of energy and peak power. - return pulse_peak_power(cast("float", self.pulse.energy), self.fwhm, self.shape) - - -DesignTarget = ZDW | RDW | SolitonOrder -"""A target of :func:`solve_parameter`.""" - -RootChoice = Literal["lowest", "highest"] | int -"""Which of several roots: the lowest, the highest, or an index into them.""" - -DesignBuild = Callable[[float], Mode | tuple[Mode, float]] -"""A builder: a trial value of the parameter to a mode, or to a mode and the fill's -nonlinear coefficient in rad/(W m) (:func:`nonlinear_coefficient`).""" - - -@dataclass(frozen=True) -class DesignRoots: - """Every root of a design target over a parameter's search range. - - Iterating gives the roots, and its length is their number. - - Attributes - ---------- - roots : tuple of float - The values of the parameter that meet the target, ascending, in the - parameter's unit; empty when none does. - search : tuple of float - The search range, ``(low, high)``. - target : ZDW, RDW or SolitonOrder - The target. - builds : int - How many times the builder was called, the search's cost. - quantity : str - What `attainable` measures, with its unit: the zero-dispersion wavelength - nearest the target's (m), the phase-matched wavelength nearest the target's - (m), or the soliton order. - attainable : tuple of float or None - When there is no root, the smallest and largest `quantity` on a coarser grid - of the search range (17 linear and, where ``low > 0``, 17 logarithmic - values, the ends included), where it is defined: a mode with no zero - dispersion in 100 nm to 3 um, or with no phase-matched wavelength in the - RDW window or no soliton, has none. ``None`` when there are roots, or no - value defines it. - - Examples - -------- - >>> from solprop.tools import DesignRoots, ZDW - >>> roots = DesignRoots((1.0, 2.0), (0.5, 3.0), ZDW(5e-7), 260, - ... "zero-dispersion wavelength in m") - >>> len(roots), roots.pick("highest"), roots.pick(0) - (2, 2.0, 1.0) - """ - - roots: tuple[float, ...] - search: tuple[float, float] - target: DesignTarget - builds: int - quantity: str - attainable: tuple[float, float] | None = None - - def __len__(self) -> int: - """Return the number of roots. - - Returns - ------- - int - The number of roots. - """ - return len(self.roots) - - def __iter__(self) -> Iterator[float]: - """Iterate over the roots, ascending. - - Returns - ------- - Iterator - The roots. - """ - return iter(self.roots) - - def pick(self, root: RootChoice = "lowest") -> float: - """Return one root. - - Parameters - ---------- - root : {'lowest', 'highest'} or int, optional - The lowest or highest root, or an index into the ascending roots - (negative from the end, as a sequence's). - - Returns - ------- - float - The root, in the parameter's unit. - - Raises - ------ - ToolsError - If there is no root, naming the attainable range, or the index is out of - range, naming the roots. - - Examples - -------- - >>> from solprop.tools import DesignRoots, ZDW - >>> DesignRoots((1.0, 2.0), (0.5, 3.0), ZDW(5e-7), 260, "").pick(-1) - 2.0 - """ - if not self.roots: - raise ToolsError(self._unreachable()) - if root == "lowest": - return self.roots[0] - if root == "highest": - return self.roots[-1] - if isinstance(root, bool) or not isinstance(root, int): - raise ToolsError( - f"root must be 'lowest', 'highest' or an index, got {root!r}" - ) - count = len(self.roots) - if not -count <= root < count: - found = ", ".join(f"{value:.6g}" for value in self.roots) - raise ToolsError( - f"root index {root} is out of range: {count} root" - f"{'' if count == 1 else 's'}, at {found}" - ) - return self.roots[root] - - def _unreachable(self) -> str: - """Return why no root was found.""" - low, high = self.search - if self.attainable is None: - seen = f"the {self.quantity} is undefined across it" - else: - seen = ( - f"across it the {self.quantity} spans " - f"{self.attainable[0]:.6g} to {self.attainable[1]:.6g}" - ) - return ( - f"no value between {low:.6g} and {high:.6g} gives " - f"{_describe(self.target)}: {seen}" - ) - - -def _describe(target: DesignTarget) -> str: - """Return a target in words, for messages. - - - Returns: for example ``"a zero-dispersion wavelength of 500 nm"``. - """ - if isinstance(target, ZDW): - return f"a zero-dispersion wavelength of {target.wavelength * 1e9:.6g} nm" - if isinstance(target, RDW): - return ( - f"a dispersive wave at {target.rdw * 1e9:.6g} nm from the pump at " - f"{target.pump * 1e9:.6g} nm" - ) - return f"a soliton order of {target.order:.6g}" - - -def _quantity_name(target: DesignTarget) -> str: - """Return what a target's attainable range measures, with its unit.""" - if isinstance(target, ZDW): - return "zero-dispersion wavelength nearest the target (m)" - if isinstance(target, RDW): - return "phase-matched wavelength nearest the target (m)" - return "soliton order" - - -def _needs_gamma(target: DesignTarget) -> bool: - """Return whether a target needs the fill's nonlinear coefficient.""" - if isinstance(target, RDW): - return target.peak_power is not None - return isinstance(target, SolitonOrder) - - -def _pump_dispersion(mode: Mode, wavelength: float, z: float) -> float: - """Return the group-velocity dispersion at a pump, refusing one not finite. - - - ``wavelength``: pump wavelength in m. - - ``z``: position along the waveguide, m. - - Returns: ``beta_2`` in s^2/m. - """ - dispersion = float(base.beta2(mode, np.array([_omega(wavelength)]), z)[0]) - if not math.isfinite(dispersion): - raise ToolsError( - f"the pump dispersion is not finite at {wavelength * 1e9:.6g} nm in a " - "trial mode" - ) - return dispersion - - -def _rdw_residual( - target: RDW, mode: Mode, gamma: float | None, z: float -) -> float | None: - """Return the phase mismatch at a dispersive-wave target, as the RDW search uses it. - - - ``gamma``: the mode's nonlinear coefficient in rad/(W m); given with a peak power. - - ``z``: position along the waveguide, m. - - Returns: mismatch in 1/m, or None when the largest constituent does not exist: in - normal dispersion, or at a pulse area at or below the first threshold. - """ - if target.peak_power is not None and target.fwhm is not None: - dispersion = _pump_dispersion(mode, target.pump, z) - if dispersion >= 0: - return None - order = soliton_order( - target.peak_power, - target.fwhm, - cast("float", gamma), - dispersion, - target.shape, - ) - if order * _area_per_order(target.shape) <= 0.5: - return None - return float( - phase_mismatch( - mode, - np.array([_omega(target.rdw)]), - target.pump, - z=z, - peak_power=target.peak_power, - gamma=gamma, - fwhm=target.fwhm, - shape=target.shape, - nonlinear_phase=target.nonlinear_phase, - )[0] - ) - - -def _target_residual( - target: DesignTarget, mode: Mode, gamma: float | None, z: float -) -> float | None: - """Return a target's residual on one trial mode. - - - ``gamma``: its nonlinear coefficient in rad/(W m), where the target needs one. - - ``z``: position along the waveguide, m. - - Returns: ``beta_2`` in s^2/m, the mismatch in 1/m (None where no soliton exists), - or the soliton order less the target's. - """ - if isinstance(target, ZDW): - return float(base.beta2(mode, np.array([_omega(target.wavelength)]), z)[0]) - if isinstance(target, RDW): - return _rdw_residual(target, mode, gamma, z) - dispersion = _pump_dispersion(mode, target.pulse.wavelength, z) - order = soliton_order( - target.peak_power, target.fwhm, cast("float", gamma), dispersion, target.shape - ) - return order - target.order - - -def _rdw_window(target: RDW) -> tuple[float, float]: - """Return the window an RDW target's attainable wavelengths are searched in. - - - Returns: :func:`lambda_rdw`'s default window, 100 nm to 0.9 of the pump, when the - target is on the blue side of the pump, widened to include it; and the mirror of - it, 1/0.9 of the pump to 3 um, on the red side. - """ - if target.rdw < target.pump: - return (min(_ZDW_RANGE[0], 0.9 * target.rdw), 0.9 * target.pump) - return (target.pump / 0.9, max(_ZDW_RANGE[1], target.rdw / 0.9)) - - -def _target_quantity( - target: DesignTarget, mode: Mode, gamma: float | None, z: float -) -> float: - """Return what a target measures on one trial mode, for the attainable range. - - - ``gamma``: its nonlinear coefficient in rad/(W m), where the target needs one. - - ``z``: position along the waveguide, m. - - Returns: the zero-dispersion or phase-matched wavelength nearest the target's in - m, or the soliton order; NaN where the mode has none. - """ - if isinstance(target, ZDW): - return _zdw_near(mode, _omega(target.wavelength), z) - if isinstance(target, SolitonOrder): - residual = _target_residual(target, mode, gamma, z) - return target.order + cast("float", residual) - if _rdw_residual(target, mode, gamma, z) is None: - return math.nan - try: - roots = lambda_rdw_all( - mode, - target.pump, - z=z, - peak_power=target.peak_power, - gamma=gamma, - fwhm=target.fwhm, - shape=target.shape, - nonlinear_phase=target.nonlinear_phase, - wavelength_limits=_rdw_window(target), - ) - except ToolsError: - # The window reaches beyond the mode's index model; this is a diagnostic - # for a target already found unreachable, so the sample has no wavelength. - return math.nan - if not roots: - return math.nan - return min(roots, key=lambda root: abs(root - target.rdw)) - - -def _built( - build: DesignBuild, value: float, target: DesignTarget -) -> tuple[Mode, float | None]: - """Call a builder and check what it returned. - - - Returns: the mode, and the nonlinear coefficient in rad/(W m), or None where the - target does not use one. - """ - result = build(value) - if isinstance(result, tuple): - if len(result) != 2: - raise ToolsError( - f"build({value:g}) returned a tuple of {len(result)}; give a mode " - "or (mode, gamma)" - ) - mode, gamma = result - gamma = positive(gamma, "gamma", ToolsError) - else: - mode, gamma = result, None - if not isinstance(mode, Mode): - raise ToolsError( - f"build({value:g}) returned {type(mode).__name__}, not a mode or " - "(mode, gamma)" - ) - if not _needs_gamma(target): - # One builder serves every target: a linear target ignores the coefficient. - return mode, None - if gamma is None: - raise ToolsError( - f"{_describe(target)} needs the fill's nonlinear coefficient: build " - "returns (mode, gamma), with gamma from nonlinear_coefficient" - ) - return mode, gamma - - -def solve_parameter( - build: DesignBuild, - search: tuple[float, float], - target: DesignTarget, - *, - z: float = 0.0, -) -> DesignRoots: - """Return every value of a guide or fill parameter that meets a design target. - - The general form of :func:`pressure_zdw`, :func:`pressure_rdw` and - :func:`radius_zdw`, for any mode and any parameter: a radius, a pressure, a core - size or a wall thickness. The target's residual is sampled over `search` on 129 - linear values and, where ``low > 0``, 129 logarithmic ones (as - :func:`pressure_rdw` samples pressure); each sign change between two samples is - refined with Brent's method to a relative tolerance of 2e-12; and a refined - change is kept as a root only where the residual has fallen to a thousandth of - its value at the bracket's ends, which discards a change through a pole (a wall - resonance, where it diverges instead). Roots closer together than one sample - are not separated: narrow `search` to resolve them. - - Parameters - ---------- - build : callable - ``build(value)`` returns the mode at a trial value of the parameter, or - ``(mode, gamma)`` with the fill's nonlinear coefficient in rad/(W m) - (:func:`nonlinear_coefficient`), which an RDW target with a peak power and a - soliton-order target need: a mode need not know its fill's Kerr response. - search : tuple of float - Finite ordered range of the parameter, ``(low, high)``, in its unit. - target : ZDW, RDW or SolitonOrder - What the parameter should achieve. - z : float, optional - Position along the waveguide at which each mode is evaluated, m: the launch - position. - - Returns - ------- - DesignRoots - Every root, ascending, with the attainable range of the target's quantity - when there is none; ``.pick(root)`` chooses one. - - Raises - ------ - ToolsError - If the range or position is not finite or not ordered, the builder returns - something other than a mode (and a coefficient where one is needed), a - residual is not finite, or a refinement crosses a no-soliton region. - - Examples - -------- - The radius of a helium capillary at 1 bar with its zero dispersion at 400 nm, - re-evaluating one mode at each trial radius: - - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import ZDW, solve_parameter - >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) - >>> roots = solve_parameter( - ... lambda radius: mode.at_parameters({"core_radius": radius}), - ... (20e-6, 500e-6), - ... ZDW(400e-9), - ... ) - >>> [f"{radius * 1e6:.2f} um" for radius in roots] - ['89.49 um'] - """ - low, high = (float(value) for value in search) - if not (math.isfinite(low) and math.isfinite(high) and low < high): - raise ToolsError(f"search must be finite and ordered, got {search!r}") - if not math.isfinite(z): - raise ToolsError(f"z must be finite, got {z!r}") - builds = 0 - - def _residual(value: float) -> float | None: - """Build the mode at one value and return the target's residual. - - - Returns: the residual, or None where the target is undefined. - """ - nonlocal builds - builds += 1 - mode, gamma = _built(build, value, target) - return _target_residual(target, mode, gamma, z) - - sampled = _sampled_roots( - _residual, (low, high), what="the target's residual", narrow="search" - ) - # Outside the probing context, so a root beyond an index model's range warns. - for root in sampled.roots: - _residual(root) - attainable = None - if not sampled.roots: - # The modes are rebuilt rather than kept from the search, on a coarser grid: - # a tabulated mode can be large, and only a search without a root pays. - with _probing(): - values = [] - for value in _search_grid(low, high, _ATTAINABLE_SAMPLES): - builds += 1 - mode, gamma = _built(build, float(value), target) - values.append(_target_quantity(target, mode, gamma, z)) - finite = [value for value in values if math.isfinite(value)] - if finite: - attainable = (min(finite), max(finite)) - return DesignRoots( - roots=sampled.roots, - search=(low, high), - target=target, - builds=builds, - quantity=_quantity_name(target), - attainable=attainable, - ) - - -def radius_zdw( - gas: Gas | str, - pressure: float, - zdw_wavelength: float, - *, - radius_limits: tuple[float, float] = (5e-6, 1e-3), - temperature: float = ROOM_TEMPERATURE, - m: int = 1, - model: MarcatiliModel = "full", - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, - root: RootChoice = "lowest", -) -> float: - """Return the core radius that puts a capillary's zero dispersion at a wavelength. - - The radius counterpart of :func:`pressure_zdw`, through - :func:`solve_parameter`: the capillary is built once at `pressure` and - re-evaluated at each trial radius. - - Parameters - ---------- - gas : Gas or str - A single built-in gas. - pressure : float - Fill pressure in **bar**, > 0. - zdw_wavelength : float - Wavelength at which the dispersion should vanish, m. - radius_limits : tuple of float, optional - Positive ordered search range of the core radius, m. - temperature : float, optional - Fill temperature in K. - m : int, optional - Radial index of the HE_1m mode. - model : {'full', 'reduced'}, optional - Marcatili model. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Design the capillary `prop_capillary` builds under the same - ``materials=``, as :func:`pressure_zdw` does. ``None`` is - ``MaterialSelection()``. - root : {'lowest', 'highest'} or int, optional - Which root, should there be several (``DesignRoots.pick``). - - Returns - ------- - float - Core radius in m. - - Raises - ------ - ToolsError - If the radius limits are not positive and ordered, the pressure is not - positive, or no radius in the range gives the zero (naming the range of - zero-dispersion wavelengths it does give). - - Examples - -------- - >>> from solprop.tools import radius_zdw - >>> f"{radius_zdw('He', 1.0, 400e-9, cladding=1.45) * 1e6:.2f} um" - '89.49 um' - """ - low, high = radius_limits - if not (math.isfinite(low) and math.isfinite(high) and 0.0 < low < high): - raise ToolsError( - f"radius_limits must be finite, positive and ordered, got {radius_limits!r}" - ) - materials = MaterialSelection() if materials is None else materials - capillary = _capillary_at( - _design_capillary( - high, - gas, - temperature=temperature, - m=m, - model=model, - cladding=cladding, - lattice=None, - materials=materials, - ), - positive(pressure, "pressure", ToolsError), - ) - roots = solve_parameter( - lambda radius: capillary.at_parameters({"core_radius": radius}), - (low, high), - ZDW(zdw_wavelength), - ) - return roots.pick(root) diff --git a/src/solprop/tools/__init__.py b/src/solprop/tools/__init__.py new file mode 100644 index 00000000..b34c30c9 --- /dev/null +++ b/src/solprop/tools/__init__.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""Design calculators: soliton numbers and dispersive-wave phase matching. + +The numbers a person works out before a run. For a pulse of energy :math:`E` and +full width at half maximum :math:`\tau_{\rm fwhm}` in a mode with group-velocity +dispersion :math:`\beta_2`, effective area :math:`A_{\rm eff}` and a fill of nonlinear +index :math:`n_2`, + +.. math:: + \gamma = \frac{n_2\omega_0}{cA_{\rm eff}},\quad + L_D = \frac{\tau_0^2}{|\beta_2|},\quad + L_{NL} = \frac{1}{\gamma P_0},\quad + N = \sqrt{L_D/L_{NL}},\quad + L_{\rm fiss} = L_D/N, + +with the natural width :math:`\tau_0` and the peak power :math:`P_0` of the chosen +pulse shape (:func:`pulse_width`, :func:`pulse_peak_power`). One shape runs through the +whole chain, so the Gaussian numbers are never computed with a ``sech`` width. + +A dispersive wave is phase matched to a soliton at the frequencies where + +.. math:: + \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) + - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} + +vanishes (:func:`phase_mismatch`). The last term is the soliton's nonlinear wavenumber +and uses the largest unchirped constituent when pulse data are given +(:func:`soliton_content`). Without pulse data the condition is linear. +``docs/guide/workflows/design-tools.md`` derives both and says what the nonlinear term +assumes. + +Everything is SI except pressure, which is in bar as at the rest of the user interface +(``docs/dev/conventions.md`` section 1). +""" + +from solprop.tools.beam import ( + BeamParams, + beam_params, + beam_through_lens, + field_to_intensity, + intensity_to_field, + self_focusing_power, +) +from solprop.tools.capillary import ( + GasRatio, + SolitonParams, + capillary_design, + capillary_params, + gas_ratio, + soliton_params, +) +from solprop.tools.design import ( + RDW, + ZDW, + DesignBuild, + DesignRoots, + DesignTarget, + RootChoice, + SolitonOrder, + pressure_rdw, + pressure_zdw, + radius_zdw, + solve_parameter, +) +from solprop.tools.phase_matching import ( + NonlinearPhase, + lambda_rdw, + lambda_rdw_all, + phase_mismatch, +) +from solprop.tools.pulse import PulseShape, pulse_energy, pulse_peak_power, pulse_width +from solprop.tools.soliton import ( + SolitonContent, + dispersion_length, + fission_length, + nonlinear_coefficient, + nonlinear_length, + soliton_content, + soliton_order, + soliton_peak_power, +) +from solprop.tools.waveguide import Fill + +__all__ = [ + "RDW", + "ZDW", + "BeamParams", + "DesignBuild", + "DesignRoots", + "DesignTarget", + "Fill", + "GasRatio", + "NonlinearPhase", + "PulseShape", + "RootChoice", + "SolitonContent", + "SolitonOrder", + "SolitonParams", + "beam_params", + "beam_through_lens", + "capillary_design", + "capillary_params", + "dispersion_length", + "field_to_intensity", + "fission_length", + "gas_ratio", + "intensity_to_field", + "lambda_rdw", + "lambda_rdw_all", + "nonlinear_coefficient", + "nonlinear_length", + "phase_mismatch", + "pressure_rdw", + "pressure_zdw", + "pulse_energy", + "pulse_peak_power", + "pulse_width", + "radius_zdw", + "self_focusing_power", + "soliton_content", + "soliton_order", + "soliton_params", + "soliton_peak_power", + "solve_parameter", +] diff --git a/src/solprop/tools/beam.py b/src/solprop/tools/beam.py new file mode 100644 index 00000000..281f2c10 --- /dev/null +++ b/src/solprop/tools/beam.py @@ -0,0 +1,299 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Free-space beam numbers: the critical power, beam parameters and intensities.""" + +import math +from dataclasses import dataclass + +import numpy as np +from jaxtyping import Float + +from solprop import spatial +from solprop._validation import positive +from solprop.constants import EPS0, C +from solprop.exceptions import ( + ToolsError, +) +from solprop.physdata import ( + Gas, + chi3, + chi3_from_n2, + refractive_index, +) +from solprop.tools.pulse import _omega + +__all__ = [ + "BeamParams", + "beam_params", + "beam_through_lens", + "field_to_intensity", + "intensity_to_field", + "self_focusing_power", +] + + +def self_focusing_power(wavelength: float, index: float, n2: float) -> float: + r"""Return the critical power for self-focusing, as physical power. + + :math:`P_{\rm cr} = 1.86226\,\lambda^2/(4\pi n_0 n_2)`, the Townes power of Fibich + and Gaeta (Opt. Lett. 25, 335 (2000)). It is + :func:`solprop.spatial.critical_power` -- the package's one Townes constant -- + at the linear index, both measuring the physical axial power in the medium + (SEP 34). The paper prints the constant as 1.86225; the + computed value is 1.8622555, of which 1.86226 is the rounding. + + Parameters + ---------- + wavelength : float + Vacuum wavelength in m, > 0. + index : float + Linear refractive index :math:`n_0`, > 0. + n2 : float + Nonlinear index in m^2/W, > 0. + + Returns + ------- + float + Critical power in W. + + Examples + -------- + >>> from solprop.tools import self_focusing_power + >>> f"{self_focusing_power(800e-9, 1.0, 3.55e-25) / 1e9:.2f}" + '267.17' + """ + linear = positive(index, "index", ToolsError) + chi3 = chi3_from_n2(positive(n2, "n2", ToolsError), linear) + return spatial.critical_power( + positive(wavelength, "wavelength", ToolsError), chi3, index=linear + ) + + +@dataclass(frozen=True) +class BeamParams: + r"""The characteristic numbers of a Gaussian beam in a gas. + + Attributes + ---------- + waist : float + The 1/e² intensity radius at the waist, m. + focus : float + Where the waist lies from the launch plane, m. + wavelength : float + The vacuum wavelength, m. + index : float + The linear refractive index. + rayleigh_range : float + :math:`\pi n w_0^2/\lambda`, m. + critical_power : float + The Townes critical power, W (:func:`solprop.spatial.critical_power`). + peak_power : float + The beam's peak power, W. + power_ratio : float + Its peak power over the critical power. + collapse_distance : float or None + Where a monochromatic beam in a pure Kerr medium would collapse by Marburger's + empirical formula, m from the launch plane, with a lens's focus combined by + Talanov's law. A reference, not a prediction of a broadband, ionising run; + ``None`` at or below the critical power, or where a beam already diverging + leaves no collapse ahead. + """ + + waist: float + focus: float + wavelength: float + index: float + rayleigh_range: float + critical_power: float + peak_power: float + power_ratio: float + collapse_distance: float | None + + def radius(self, z: Float[np.ndarray, "*shape"]) -> Float[np.ndarray, "*shape"]: + """Return the linear beam radius along z, in the absence of any nonlinearity. + + Parameters + ---------- + z : numpy.ndarray + Positions from the launch plane, m. + + Returns + ------- + numpy.ndarray + The 1/e² intensity radius, m. + """ + offset = (np.asarray(z, dtype=float) - self.focus) / self.rayleigh_range + return self.waist * np.sqrt(1.0 + offset**2) + + +def beam_params( + waist: float, + wavelength: float, + gas: Gas | str, + pressure: float, + peak_power: float, + *, + focus: float = 0.0, +) -> BeamParams: + r"""Return the characteristic numbers of a Gaussian beam in a gas. + + The Rayleigh range, the critical power, and Marburger's collapse distance + :math:`0.367 z_R/\sqrt{(\sqrt{P/P_{\rm cr}}-0.852)^2-0.0219}`, for a beam collimated + at the launch plane. A beam launched a distance :math:`d` before its waist has, at + launch, the radius :math:`w_0\sqrt{1 + (d/z_R)^2}` and a wavefront converging with + radius :math:`R = d + z_R^2/d`; Marburger's distance for that radius is combined + with :math:`R` by Talanov's lens law, :math:`1/z_c' = 1/z_c + 1/R`. A waist just + ahead is nearly collimated, so the distance moves smoothly with :math:`d`. + + Parameters + ---------- + waist : float + The 1/e² intensity radius at the waist, m. + wavelength : float + The vacuum wavelength, m. + gas : Gas or str + The gas. + pressure : float + Its pressure, bar. + peak_power : float + The beam's peak power, W. + focus : float, optional + Where the waist lies from the launch plane, m. + + Returns + ------- + BeamParams + The numbers. + + Examples + -------- + >>> from solprop.tools import beam_params + >>> beam = beam_params(300e-6, 800e-9, "Ar", 1.0, 27.6e9) + >>> f"{beam.rayleigh_range:.3f} m, P/Pcr {beam.power_ratio:.2f}" + '0.354 m, P/Pcr 3.00' + >>> f"{beam.collapse_distance:.3f} m" + '0.150 m' + """ + omega = _omega(wavelength) + index = float(refractive_index(gas, omega, pressure=pressure)) + power = spatial.critical_power( + wavelength, chi3(gas, pressure=pressure, wavelength=wavelength), index=index + ) + rayleigh = np.pi * index * positive(waist, "waist", ToolsError) ** 2 / wavelength + ratio = positive(peak_power, "peak_power", ToolsError) / power + collapse = None + if ratio > 1.0: + # (sqrt(r) - 0.852)^2 - 0.0219 vanishes at r = 1, so it is positive above it. + root = (np.sqrt(ratio) - 0.852) ** 2 - 0.0219 + # At launch: the radius there, and the wavefront's radius of curvature. + launch = rayleigh * (1.0 + (focus / rayleigh) ** 2) + collapse = float(0.367 * launch / np.sqrt(root)) + if focus != 0: + curvature = focus + rayleigh**2 / focus + inverse = 1.0 / collapse + 1.0 / curvature + collapse = float(1.0 / inverse) if inverse > 0 else None + return BeamParams( + waist, + focus, + wavelength, + index, + float(rayleigh), + float(power), + peak_power, + float(ratio), + collapse, + ) + + +def beam_through_lens( + focal_length: float, radius: float, wavelength: float +) -> tuple[float, float]: + r"""Return the waist a thin lens makes of a collimated Gaussian beam. + + :math:`w_0 = \lambda f / (\pi w) / \sqrt{1 + (\lambda f / \pi w^2)^2}`, and the + waist's distance from the lens, :math:`f / (1 + (\lambda f / \pi w^2)^2)`, which + falls short of the focal length for a weakly focused beam. Paraxial, in vacuum. + + Parameters + ---------- + focal_length : float + The lens's focal length, m. + radius : float + The beam's 1/e² intensity radius on the lens, m. + wavelength : float + The vacuum wavelength, m. + + Returns + ------- + tuple of float + The waist, m, and its distance from the lens, m. + + Examples + -------- + >>> from solprop.tools import beam_through_lens + >>> waist, distance = beam_through_lens(1.0, 5e-3, 800e-9) + >>> f"{waist * 1e6:.1f} um at {distance:.4f} m" + '50.9 um at 0.9999 m' + """ + focal = positive(focal_length, "focal_length", ToolsError) + beam = positive(radius, "radius", ToolsError) + light = positive(wavelength, "wavelength", ToolsError) + ratio = light * focal / (np.pi * beam**2) + factor = 1.0 / (1.0 + ratio**2) + return float(light * focal / (np.pi * beam) * np.sqrt(factor)), float( + focal * factor + ) + + +def field_to_intensity(field: float) -> float: + r"""Return the intensity of a field amplitude, :math:`I = \epsilon_0 c E^2/2`. + + Parameters + ---------- + field : float + Field amplitude in V/m. + + Returns + ------- + float + Intensity in W/m^2. + + Examples + -------- + >>> from solprop.tools import field_to_intensity + >>> f"{field_to_intensity(1e10):.4e}" + '1.3272e+17' + """ + return 0.5 * EPS0 * C * float(field) ** 2 + + +def intensity_to_field(intensity: float) -> float: + r"""Return the field amplitude of an intensity, :math:`E = \sqrt{2I/\epsilon_0 c}`. + + Parameters + ---------- + intensity : float + Intensity in W/m^2, >= 0. + + Returns + ------- + float + Field amplitude in V/m. + + Raises + ------ + ToolsError + If the intensity is negative. + + Examples + -------- + >>> from solprop.tools import field_to_intensity, intensity_to_field + >>> f"{intensity_to_field(field_to_intensity(1e10)):.6e}" + '1.000000e+10' + """ + if not intensity >= 0.0: + raise ToolsError(f"an intensity must not be negative, got {intensity!r}") + return math.sqrt(2.0 * float(intensity) / (EPS0 * C)) diff --git a/src/solprop/tools/capillary.py b/src/solprop/tools/capillary.py new file mode 100644 index 00000000..1d138d34 --- /dev/null +++ b/src/solprop/tools/capillary.py @@ -0,0 +1,843 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Soliton parameters of a mode or a gas-filled capillary, and capillary designs. + +The full set of design numbers for a pulse in a mode (:func:`soliton_params`) or a +capillary (:func:`capillary_params`), the capillary that gives a soliton order +(:func:`capillary_design`) and the gas mixing ratio of a bulk dispersion +(:func:`gas_ratio`). +""" + +import math +from dataclasses import dataclass +from typing import cast, get_args + +import numpy as np +from jaxtyping import Float + +from solprop._display import Displayed, line +from solprop._validation import choice, positive +from solprop.constants import ROOM_TEMPERATURE, C +from solprop.exceptions import ( + ModeError, + ToolsError, +) +from solprop.modes import base +from solprop.modes.capillary import LossSpec, MarcatiliMode, MarcatiliModel, ModeKind +from solprop.modes.protocol import Mode +from solprop.modes.quadrature import BASIS_NORMALISATION, mode_matrix, on_axis_rule +from solprop.physdata import ( + Gas, + Mixture, +) +from solprop.physdata.glasses import GlassIndex +from solprop.physdata.selection import MaterialSelection +from solprop.tools.beam import self_focusing_power +from solprop.tools.design import pressure_zdw +from solprop.tools.pulse import ( + PulseShape, + _omega, + pulse_energy, + pulse_peak_power, + pulse_width, +) +from solprop.tools.soliton import ( + SolitonContent, + _area_per_order, + _gaussian_order, + dispersion_length, + fission_length, + nonlinear_coefficient, + nonlinear_length, + soliton_content, + soliton_order, + soliton_peak_power, +) +from solprop.tools.waveguide import ( + Fill, + _check_carrier, + _default_cladding, + _fill_chi3, + _fill_n2, + _resolve_fill, + _zdw_near, +) + +__all__ = [ + "GasRatio", + "SolitonParams", + "capillary_design", + "capillary_params", + "gas_ratio", + "soliton_params", +] + + +@dataclass(frozen=True) +class GasRatio: + """Ratios of the bulk dispersion and the Kerr response of two gases. + + Attributes + ---------- + beta2 : float + Ratio of the bulk group-velocity dispersions, first gas over second. + chi3 : float + Ratio of the third-order susceptibilities, first gas over second. + """ + + beta2: float + chi3: float + + +@dataclass(frozen=True, eq=False) +class SolitonParams(Displayed): + """The design numbers of one pulse in one mode and one fill. + + Attributes + ---------- + energy : float + Pulse energy in J. + fwhm : float + Full width at half maximum of the power in s. + width : float + Natural width ``tau0`` of the shape in s (:func:`pulse_width`). + omega : float + Angular frequency of the pump in rad/s. + wavelength : float + Vacuum wavelength of the pump in m. + gas : str + The fill's species labels, joined by ``+``. + pressure : float + Total pressure at the position in bar; NaN for a mixture of independent + populations, which has no single pressure. + temperature : float + Fill temperature in K. + shape : {'sech', 'gauss'} + ``"sech"`` or ``"gauss"``. + peak_power : float + Peak power in W. + beta2 : float + Group-velocity dispersion of the mode at the pump in s^2/m. + density : float + Total number density of the fill in m^-3. + index : float + Linear refractive index of the fill at the pump. + n2 : float + Nonlinear index of the fill in m^2/W. + gamma : float + Nonlinear coefficient in rad/(W m). + soliton_order : float + ``sqrt(L_D / L_NL)``. + soliton_content : SolitonContent or None + Unchirped NLSE constituent count and largest soliton; None for normal GVD. + intensity : float + Mode-averaged intensity ``peak_power / effective_area`` in W/m^2. + peak_intensity : float + On-axis intensity of the mode at the peak power in W/m^2: what a run's + ``peak_intensity`` statistic reports at its first plane. For the fundamental + of a capillary it is 1.77 times `intensity`. NaN for a mode without a + transverse profile (a dispersion-specified one). + critical_power : float + Self-focusing power in W (:func:`self_focusing_power`). + dispersion_length : float + In m. + nonlinear_length : float + In m. + fission_length : float + In m. + zdw : float + Zero-dispersion wavelength in m within 100 nm to 3 um, or NaN when the + dispersion does not change sign there. No second, guess-seeded search is + tried. + loss_length : float + The 1/e **power** loss length of the mode at the pump in m. + effective_area : float + In m^2. + mode : Mode + The mode. + """ + + energy: float + fwhm: float + width: float + omega: float + wavelength: float + gas: str + pressure: float + temperature: float + shape: PulseShape + peak_power: float + beta2: float + density: float + index: float + n2: float + gamma: float + soliton_order: float + soliton_content: SolitonContent | None + intensity: float + peak_intensity: float + critical_power: float + dispersion_length: float + nonlinear_length: float + fission_length: float + zdw: float + loss_length: float + effective_area: float + mode: Mode + + def __repr__(self) -> str: + """Return a one-line description: the pulse, the fill and the soliton order. + + Returns + ------- + str + For example ``SolitonParams(300 uJ, 10 fs at 800 nm, He 0.4 bar, N=2.23)``. + """ + return line( + "SolitonParams", + [ + ("", f"{self.energy * 1e6:.4g} uJ"), + ("", f"{self.fwhm * 1e15:.4g} fs at {self.wavelength * 1e9:.4g} nm"), + ("", f"{self.gas} {self.pressure:.4g} bar"), + ("N", f"{self.soliton_order:.3g}"), + ], + ) + + def __str__(self) -> str: + """Return the parameter summary, section by section. + + Returns + ------- + str + MODE, WAVEGUIDE, FILL, PULSE, DISPERSION, POWER, INTENSITY and SOLITON, + in the customary units: ``n2`` in cm^2/W, intensities in W/cm^2. + """ + zdw = ( + "none in 100-3000 nm" + if math.isnan(self.zdw) + else f"{self.zdw * 1e9:.1f} nm" + ) + ratio = self.peak_power / self.critical_power + sections = ( + ("MODE", repr(self.mode)), + ( + "WAVEGUIDE", + f"Aeff = {self.effective_area:.2e} m^2, " + f"Lloss = {self.loss_length:.2e} m (1/e power)", + ), + ( + "FILL", + f"{self.pressure:.3g} bar {self.gas} at {self.temperature:g} K, " + f"Pcr = {self.critical_power:.2e} W,\n " + f"gamma = {self.gamma:.2e} /(W m), n2 = {self.n2 * 1e4:.2e} cm^2/W", + ), + ( + "PULSE", + f"{self.energy:.3e} J, {self.fwhm:.3e} s @ " + f"{self.wavelength * 1e9:.1f} nm (shape: {self.shape})", + ), + ( + "DISPERSION", + f"{self.beta2:.3e} s^2/m @ {self.wavelength * 1e9:.1f} nm, ZDW = {zdw}", + ), + ("POWER", f"{self.peak_power:.2e} W ({ratio:.4f} of Pcr)"), + ( + "INTENSITY", + f"{self.intensity * 1e-4:.2e} W/cm^2 mode-averaged, " + f"{self.peak_intensity * 1e-4:.2e} W/cm^2 on axis", + ), + ( + "SOLITON", + f"Ld = {self.dispersion_length:.2e} m, Lnl = " + f"{self.nonlinear_length:.2e} m, Lfiss = {self.fission_length:.2e} m, " + f"N = {self.soliton_order:.2f}", + ), + ) + content = self.soliton_content + if content is None: + constituents = "unavailable (normal dispersion)" + elif content.count == 0: + constituents = "0 discrete solitons" + else: + constituents = ( + f"{content.count} discrete solitons, " + f"area/pi = {content.area_order:.3g}\n " + f"largest P = {content.largest_peak_power:.3e} W, " + f"T = {content.largest_width:.3e} s, " + f"kNL = {content.largest_nonlinear_wavenumber:.3e} 1/m" + ) + return "\n".join(f"{name}:\n {text}" for name, text in sections) + ( + f"\nCONSTITUENTS (unchirped NLSE):\n {constituents}" + ) + + +def _on_axis_intensity(mode: Mode, peak_power: float, z: float) -> float: + """Return the on-axis intensity of a mode carrying a given power. + + - ``peak_power``: power in W. + - ``z``: position along the waveguide, m. + - Returns: intensity on the axis in W/m^2, both transverse components included; NaN + for a mode that declares no transverse profile. + """ + # The same synthesis the run's peak_intensity statistic uses: the normalised mode + # field at the origin in V/m per sqrt(W), so that eps0 c |e|^2 / 2 is the + # intensity per watt. + try: + on_axis = mode_matrix( + (mode,), on_axis_rule(mode.dimlimits(z)[0]), z, components=("x", "y") + ) + except ModeError: + # A dispersion-specified mode has an area and no transverse profile, so it has + # a mode-averaged intensity and no on-axis one. + return math.nan + per_watt = BASIS_NORMALISATION * float(np.sum(np.abs(on_axis.synthesis) ** 2)) + return per_watt * peak_power + + +def soliton_params( + mode: Mode, + gas: Fill, + pressure: float | None = None, + *, + energy: float, + fwhm: float, + wavelength: float, + shape: PulseShape = "sech", + temperature: float | None = None, + z: float = 0.0, + materials: MaterialSelection | None = None, +) -> SolitonParams: + """Return the design numbers of a pulse in any mode. + + The mode supplies the dispersion, the area and the loss; the fill supplies the + nonlinear index. They are separate arguments because a mode need not know its + fill's Kerr response (a tabulated mode does not), and the caller is trusted to + give the fill the mode was built with -- :func:`capillary_params` does both. + + Parameters + ---------- + mode : Mode + Any mode: a capillary, an antiresonant or tabulated fibre, a rectangular + guide. + gas : Gas, str, Mapping or Mixture + The fill: a registry species, ``"air"``, mole fractions, or a mixture. A + mixture of species selected from optmat takes their Kerr coefficients at + `wavelength`, the carrier. + pressure : float or None, optional + Total pressure in **bar**; ``None`` is one bar, and is required beside a + `Mixture`. + energy : float + Pulse energy in J, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + wavelength : float + Pump wavelength in m, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape, used consistently for the width and the peak power. + temperature : float or None, optional + Fill temperature in K; ``None`` is room temperature, or the mixture's own + beside a `Mixture`, which refuses any other. + z : float, optional + Position along the waveguide at which everything is evaluated, m. + materials : MaterialSelection or None, optional + Resolve a named fill from optmat under this selection, as a run's + ``materials=`` does: its index, density and Kerr coefficient at + `wavelength`. ``None`` is ``MaterialSelection()``; a `Mixture` is taken as + it is. The mode is the caller's either way. + + Returns + ------- + SolitonParams + The numbers, with the mode. + + Raises + ------ + ToolsError + If a quantity is not positive, the shape is unknown, the mode has no + dispersion at the pump, a component of the fill has no Kerr data, the + selection names a carrier other than `wavelength`, or a pressure or a + temperature other than its own is given beside a `Mixture`. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import soliton_params + >>> mode = MarcatiliMode(125e-6, "He", 0.4, model="reduced", cladding=1.45) + >>> p = soliton_params(mode, "He", 0.4, energy=300e-6, fwhm=10e-15, + ... wavelength=800e-9) + >>> f"{p.soliton_order:.2f} {p.fission_length:.2f}" + '2.26 1.75' + """ + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + mixture = _resolve_fill(gas, pressure, temperature, materials) + omega = _omega(wavelength) + here = np.array([float(z)]) + n2, index = _fill_n2(mixture, wavelength, float(z)) + peak_power = pulse_peak_power(energy, fwhm, shape) + beta2 = float(base.beta2(mode, np.array([omega]), z)[0]) + gamma = nonlinear_coefficient(mode, n2, wavelength, z) + area = base.aeff(mode, z) + zdw = _zdw_near(mode, omega, float(z)) + shared = mixture.shared + return SolitonParams( + energy=float(energy), + fwhm=float(fwhm), + width=pulse_width(fwhm, shape), + omega=omega, + wavelength=float(wavelength), + gas="+".join(mixture.labels), + pressure=math.nan if shared is None else float(shared(here)[0]), + # _resolve_fill refused a temperature that varies along z. + temperature=cast("float", mixture.temperature), + shape=shape, + peak_power=peak_power, + beta2=beta2, + density=float(mixture.total_density(here)[0]), + index=index, + n2=n2, + gamma=gamma, + soliton_order=soliton_order(peak_power, fwhm, gamma, beta2, shape), + soliton_content=( + soliton_content(peak_power, fwhm, gamma, beta2, shape) + if beta2 < 0 + else None + ), + intensity=peak_power / area, + peak_intensity=_on_axis_intensity(mode, peak_power, float(z)), + critical_power=self_focusing_power(wavelength, index, n2), + dispersion_length=dispersion_length(fwhm, beta2, shape), + nonlinear_length=nonlinear_length(peak_power, gamma), + fission_length=fission_length(peak_power, fwhm, gamma, beta2, shape), + zdw=zdw, + loss_length=float(base.loss_length(mode, np.array([omega]), z)[0]), + effective_area=area, + mode=mode, + ) + + +def _capillary( + radius: float, + mixture: Mixture, + *, + n: int, + m: int, + kind: ModeKind, + model: MarcatiliModel, + loss: LossSpec, + cladding: GlassIndex | complex | None, +) -> MarcatiliMode: + """Build the HE, TE or TM capillary the capillary design tools report on. + + - ``radius``: core radius in m, > 0. + - ``m``: radial mode index, >= 1. + - ``cladding``: cladding index; ``None`` is fused silica. + """ + return MarcatiliMode( + positive(radius, "radius", ToolsError), + mixture=mixture, + n=n, + m=m, + kind=kind, + model=model, + loss=loss, + cladding=_default_cladding(cladding), + ) + + +def capillary_params( + radius: float, + gas: Fill, + pressure: float | None = None, + *, + energy: float, + fwhm: float, + wavelength: float, + shape: PulseShape = "sech", + temperature: float | None = None, + n: int = 1, + m: int = 1, + kind: ModeKind = "HE", + model: MarcatiliModel = "full", + loss: LossSpec = True, + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, +) -> SolitonParams: + """Return the design numbers of a pulse in a gas-filled capillary. + + Builds the mode as :func:`solprop.driver.interface.prop_capillary` does for the same + arguments -- a :class:`~solprop.modes.capillary.MarcatiliMode` over the fill's + mixture -- and hands it to :func:`soliton_params`. + + Parameters + ---------- + radius : float + Core radius in m, > 0. + gas : Gas, str, Mapping or Mixture + The fill, as `prop_capillary` takes it. + pressure : float or None, optional + Total pressure in **bar**; ``None`` is one bar. For a gradient, evaluate at + the pressure of the position of interest. + energy : float + Pulse energy in J, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + wavelength : float + Pump wavelength in m, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape. + temperature : float or None, optional + Fill temperature in K; ``None`` is room temperature, or the mixture's own + beside a `Mixture`, which refuses any other. + n : int, optional + Azimuthal mode index. + m : int, optional + Radial mode index, >= 1. + kind : {'HE', 'TE', 'TM'}, optional + Mode family. + model : {'full', 'reduced'}, optional + Marcatili model. + loss : bool, float or callable, optional + Loss policy of the mode. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Build the capillary as `prop_capillary` does under the same ``materials=``: + the fill from optmat (index, density and Kerr coefficient at `wavelength`) + and ``cladding=None`` as optmat's recommended fused silica. ``None`` is + ``MaterialSelection()``. + + Returns + ------- + SolitonParams + The numbers, with the mode that was built. + + Examples + -------- + >>> from solprop.tools import capillary_params + >>> p = capillary_params(125e-6, "He", 0.4, energy=300e-6, fwhm=10e-15, + ... wavelength=800e-9, model="reduced", cladding=1.45) + >>> f"{p.gamma:.3e} {p.intensity:.3e}" + '4.884e-11 1.130e+18' + """ + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + mixture = _resolve_fill(gas, pressure, temperature, materials) + mode = _capillary( + radius, + mixture, + n=n, + m=m, + kind=kind, + model=model, + loss=loss, + cladding=cladding, + ) + return soliton_params( + mode, mixture, energy=energy, fwhm=fwhm, wavelength=wavelength, shape=shape + ) + + +def _target_order( + order: float | None, + count: int | None, + eigenvalue: float | None, + shape: PulseShape, +) -> float: + """Return the conventional soliton order that meets exactly one design target. + + - ``order``: a conventional soliton order, > 0. + - ``count``: a number of solitons, >= 1. + - ``eigenvalue``: the largest Zakharov-Shabat eigenvalue, > 0. + - ``shape``: the pulse shape. + """ + given = [ + name + for name, value in ( + ("soliton_order", order), + ("soliton_count", count), + ("largest_eigenvalue", eigenvalue), + ) + if value is not None + ] + if len(given) != 1: + raise ToolsError( + "give exactly one of soliton_order, soliton_count and largest_eigenvalue, " + f"got {', '.join(given) or 'none'}" + ) + if order is not None: + return positive(order, "soliton_order", ToolsError) + if count is not None: + if count < 1: + raise ToolsError(f"soliton_count must be at least 1, got {count}") + # The count is M = ceil(S - 1/2) for an area S (soliton_content), so the + # pulses with M solitons have S in (M - 1/2, M + 1/2]. The design takes the + # middle, S = M: the pure M-soliton for sech, the farthest from either + # threshold for a Gaussian. + return count / _area_per_order(shape) + eta = positive(cast("float", eigenvalue), "largest_eigenvalue", ToolsError) + return eta + 0.5 if shape == "sech" else _gaussian_order(eta) + + +def capillary_design( + radius: float, + gas: Gas | str, + *, + zdw_wavelength: float, + fwhm: float, + wavelength: float, + soliton_order: float | None = None, + soliton_count: int | None = None, + largest_eigenvalue: float | None = None, + shape: PulseShape = "sech", + temperature: float = ROOM_TEMPERATURE, + m: int = 1, + model: MarcatiliModel = "full", + loss: LossSpec = True, + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, + pressure_max: float = 100.0, +) -> SolitonParams: + r"""Return the pressure and energy that give a capillary a soliton design. + + The inverse of :func:`capillary_params`: the pressure puts the zero-dispersion + point at `zdw_wavelength` (:func:`pressure_zdw`), and the energy then gives the + pulse one soliton target. At that pressure :math:`\beta_2` and :math:`\gamma` do + not depend on the energy and :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|`, so the + peak power is :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. Give exactly one + target: + + - `soliton_order`, the conventional :math:`N`; + - `soliton_count`, a number :math:`M` of solitons in the scattering content + (:func:`soliton_content`): the pulse area is set to :math:`M\pi`, the middle + of the band of areas with :math:`M` solitons, which is :math:`N = M` for + sech and :math:`N = M\sqrt{\pi/2}` for a Gaussian; + - `largest_eigenvalue`, the :math:`\eta` of the largest constituent, which + fixes its width :math:`\tau_0/(2\eta)`: :math:`N = \eta + 1/2` for sech, and + a root of the Gaussian scattering problem otherwise. + + Parameters + ---------- + radius : float + Core radius in m, > 0. + gas : Gas or str + A single built-in gas, as :func:`pressure_zdw` takes it. + zdw_wavelength : float + Wavelength at which the dispersion should vanish, m. + fwhm : float + Full width at half maximum of the power in s, > 0. + wavelength : float + Pump wavelength in m, > 0. + soliton_order : float or None, optional + Target conventional soliton order, > 0. + soliton_count : int or None, optional + Target number of solitons, >= 1. + largest_eigenvalue : float or None, optional + Target imaginary part of the largest Zakharov-Shabat eigenvalue, + dimensionless with time in units of the shape's natural width, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape, used for the width, the peak power and the content. + temperature : float, optional + Fill temperature in K. + m : int, optional + Radial index of the HE_1m mode. + model : {'full', 'reduced'}, optional + Marcatili model. + loss : bool, float or callable, optional + Loss policy of the mode in the returned numbers. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Design under this selection, as :func:`capillary_params` and + :func:`pressure_zdw` do. ``None`` is ``MaterialSelection()``. + pressure_max : float, optional + Upper end of the pressure bracket in **bar**. + + Returns + ------- + SolitonParams + The numbers at the designed pressure and energy, with the mode; the + pressure is ``.pressure`` (bar) and the energy ``.energy`` (J). + + Raises + ------ + ToolsError + If not exactly one target is given or it is out of range, no pressure in the + bracket gives the zero-dispersion point, or a content target is asked of a + pump in normal dispersion. + + Examples + -------- + >>> from solprop.tools import capillary_design + >>> p = capillary_design(125e-6, "Ar", zdw_wavelength=500e-9, fwhm=10e-15, + ... wavelength=800e-9, soliton_order=3.0, cladding=1.45) + >>> f"{p.pressure:.4f} bar {p.energy * 1e6:.1f} uJ N = {p.soliton_order:.3f}" + '0.0664 bar 100.5 uJ N = 3.000' + >>> g = capillary_design(125e-6, "Ar", zdw_wavelength=500e-9, fwhm=10e-15, + ... wavelength=800e-9, soliton_count=2, shape="gauss", + ... cladding=1.45) + >>> f"{g.soliton_order:.4f} {g.soliton_content.count}" + '2.5066 2' + """ + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + choice(shape, "shape", get_args(PulseShape), ToolsError) + order = _target_order(soliton_order, soliton_count, largest_eigenvalue, shape) + pressure = pressure_zdw( + radius, + gas, + zdw_wavelength, + pressure_max=pressure_max, + temperature=temperature, + m=m, + model=model, + cladding=cladding, + materials=materials, + ) + mixture = _resolve_fill(gas, pressure, temperature, materials) + mode = _capillary( + radius, + mixture, + n=1, + m=m, + kind="HE", + model=model, + loss=loss, + cladding=cladding, + ) + beta2 = float(base.beta2(mode, np.array([_omega(wavelength)]))[0]) + if soliton_order is None and beta2 >= 0.0: + raise ToolsError( + f"a soliton count or eigenvalue needs anomalous dispersion at the pump, " + f"but beta2 = {beta2:.4g} s^2/m at {wavelength * 1e9:.4g} nm with the " + f"zero-dispersion point at {zdw_wavelength * 1e9:.4g} nm" + ) + n2, _ = _fill_n2(mixture, wavelength, 0.0) + gamma = nonlinear_coefficient(mode, n2, wavelength) + peak_power = soliton_peak_power(order, fwhm, gamma, beta2, shape) + energy = pulse_energy(peak_power, fwhm, shape) + return soliton_params( + mode, mixture, energy=energy, fwhm=fwhm, wavelength=wavelength, shape=shape + ) + + +def gas_ratio( + gas_a: Gas | str, + gas_b: Gas | str, + wavelength: float, + *, + pressure: float = 1.0, + temperature: float = ROOM_TEMPERATURE, + materials: MaterialSelection | None = None, +) -> GasRatio: + """Return the ratios of bulk dispersion and Kerr response of two gases. + + The bulk (unguided) dispersion of each gas, not a mode's: it answers "how much + more pressure of B matches A", which is a property of the gases alone. + + Parameters + ---------- + gas_a : Gas or str + The numerator's species. + gas_b : Gas or str + The denominator's species. + wavelength : float + Wavelength in m at which the dispersions are compared, > 0. + pressure : float, optional + Pressure of both gases in **bar**, > 0. + temperature : float, optional + Temperature of both in K. + materials : MaterialSelection or None, optional + Take both gases from optmat under this selection, as a run's ``materials=`` + does: their index, their density and their Kerr coefficient at + `wavelength`; ``"air"`` is nitrogen and oxygen. ``None`` is + ``MaterialSelection()``. + + Returns + ------- + GasRatio + Named ``beta2`` and ``chi3`` ratios. + + Raises + ------ + ToolsError + If the selection names a carrier other than `wavelength`, or a gas has no + Kerr data under it. + + Examples + -------- + >>> from solprop.tools import gas_ratio + >>> ratio = gas_ratio("Ar", "He", 800e-9) + >>> f"{ratio.beta2:.1f} {ratio.chi3:.1f}" + '18.6 28.4' + """ + omega = _omega(wavelength) + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + dispersions = [] + susceptibilities = [] + for gas in (gas_a, gas_b): + fill = _resolve_fill( + gas, positive(pressure, "pressure", ToolsError), temperature, materials + ) + dispersions.append(_bulk_beta2(_BulkGas(fill), omega)) + susceptibilities.append(_fill_chi3(fill, wavelength)) + return GasRatio( + beta2=dispersions[0] / dispersions[1], + chi3=susceptibilities[0] / susceptibilities[1], + ) + + +@dataclass(frozen=True) +class _BulkGas: + """A uniform gas as an index model, for bulk dispersion. + + Attributes + ---------- + fill : Mixture + The gas resolved under a material selection, whose index is taken. + """ + + fill: Mixture + + def excess_beta( + self, omega: Float[np.ndarray, " n_omega"] + ) -> Float[np.ndarray, " n_omega"]: + """Return the gas's part of the bulk propagation constant. + + The vacuum's ``omega / c`` has no second derivative, and leaving it out of a + difference quotient removes its rounding, which at a gas's ``n - 1`` of 1e-5 is + the larger part of the quotient. + + - ``omega``: angular frequencies in rad/s. + - Returns: ``omega (n - 1) / c`` in 1/m, with ``n - 1`` formed without + cancellation. + """ + chi = self.fill.susceptibility(omega, np.zeros(1))[0] + excess = chi / (1.0 + np.sqrt(1.0 + chi)) + return np.asarray(omega * np.real(excess) / C) + + +def _bulk_beta2(bulk: _BulkGas, omega: float) -> float: + """Return the bulk group-velocity dispersion by a Richardson central difference. + + - ``omega``: angular frequency in rad/s. + - Returns: ``d^2 beta / d omega^2`` in s^2/m. + """ + # A second difference of the gas's part of beta: only a ratio of two such numbers is + # used, so the few parts in 1e5 this reaches at the step modes.base chose are ample. + step = base.BETA2_RELATIVE_STEP * omega + + def second(h: float) -> float: + """Return the three-point second difference at step ``h``. + + - ``h``: step in rad/s. + - Returns: the difference quotient in s^2/m. + """ + values = bulk.excess_beta(np.array([omega - h, omega, omega + h])) + return float(values[0] - 2.0 * values[1] + values[2]) / h**2 + + return (4.0 * second(step / 2.0) - second(step)) / 3.0 diff --git a/src/solprop/tools/design.py b/src/solprop/tools/design.py new file mode 100644 index 00000000..cfb66cb0 --- /dev/null +++ b/src/solprop/tools/design.py @@ -0,0 +1,1297 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Design solvers: the parameter of a guide or fill that meets a design target. + +:func:`solve_parameter` brackets every root of a target's residual over a parameter; +:func:`radius_zdw`, :func:`pressure_zdw` and :func:`pressure_rdw` are its capillary +forms. +""" + +import logging +import math +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from itertools import pairwise +from typing import Literal, cast + +import numpy as np +from jaxtyping import Float +from scipy.optimize import brentq + +from solprop._validation import positive +from solprop.constants import ROOM_TEMPERATURE +from solprop.exceptions import ( + ToolsError, +) +from solprop.modes import base +from solprop.modes.antiresonant import TubeLattice, VincettiMode +from solprop.modes.capillary import MarcatiliMode, MarcatiliModel +from solprop.modes.protocol import Mode +from solprop.physdata import ( + Gas, + Mixture, +) +from solprop.physdata.glasses import GlassIndex +from solprop.physdata.selection import MaterialSelection +from solprop.pulses import GaussianPulse, SechPulse +from solprop.tools.phase_matching import ( + NonlinearPhase, + _PhasePulse, + lambda_rdw_all, + phase_mismatch, +) +from solprop.tools.pulse import _SECH_WIDTH, PulseShape, _omega, pulse_peak_power +from solprop.tools.soliton import _area_per_order, nonlinear_coefficient, soliton_order +from solprop.tools.waveguide import ( + _ZDW_RANGE, + _check_carrier, + _default_cladding, + _fill_n2, + _probing, + _resolve_fill, + _zdw_near, +) + +__all__ = [ + "RDW", + "ZDW", + "DesignBuild", + "DesignRoots", + "DesignTarget", + "RootChoice", + "SolitonOrder", + "pressure_rdw", + "pressure_zdw", + "radius_zdw", + "solve_parameter", +] + +logger = logging.getLogger(__name__) + + +_PRESSURE_FLOOR = 1e-6 +"""Lower end of the pressure brackets in bar; a true vacuum has no gas term.""" + + +_SEARCH_SAMPLES = 129 +"""Linear samples of a parameter search, and logarithmic ones beside them where the +range is positive: :func:`pressure_rdw`'s grid, which :func:`solve_parameter` shares.""" + + +_ATTAINABLE_SAMPLES = 17 +"""Linear samples, and logarithmic ones beside them, on which a search without a root +reports the attainable range of its target's quantity. Coarser than the search: the +report rebuilds each mode, and the zero-dispersion search alone costs some tens of +index evaluations per mode. Both ends of the range are among them, which is where a +quantity monotonic in the parameter takes its extremes.""" + + +_ROOT_TOLERANCE = 2e-12 +"""Brent's absolute tolerance on a bracketed root, in units of the bracket's larger +end: the default ``xtol`` of :func:`scipy.optimize.brentq` (2e-12) made relative, so a +radius in m and a pressure in bar converge to the same number of digits.""" + + +_ROOT_RESIDUAL = 1e-3 +"""Largest residual at a refined sign change, as a fraction of its larger value at the +bracket's ends, for the change to be a root. Brent's method converges onto a pole as +readily as onto a zero; at a zero the residual it ends on is of the order of the slope +times the tolerance, many orders below the ends, and at a pole it diverges past both. +A parameter has no integral like ``beta1`` to test the change against (see +:data:`_POLE_FACTOR`), so the residual's own size decides.""" + + +def _pressure_root( + residual: Callable[[float], float], pressure_max: float, what: str +) -> float: + """Return the pressure at which a function of pressure vanishes. + + - ``residual``: a function of the pressure in bar. + - ``pressure_max``: upper end of the bracket in bar. + - Returns: the root in bar. + """ + top = positive(pressure_max, "pressure_max", ToolsError) + if top <= _PRESSURE_FLOOR: + raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") + with _probing(): + low, high = residual(_PRESSURE_FLOOR), residual(top) + if not (math.isfinite(low) and math.isfinite(high)): + raise ToolsError( + f"the residual for {what} is not finite at the ends of the pressure " + f"bracket ({low!r}, {high!r}): the index model does not reach that far" + ) + if low * high > 0.0: + raise ToolsError( + f"no pressure between {_PRESSURE_FLOOR} and {top:.4g} bar gives {what}: " + f"the residual is {low:.4g} at the low end and {high:.4g} at the high end" + ) + with _probing(): + root = float(brentq(residual, _PRESSURE_FLOOR, top)) + # Outside the probing context, so the answer's own warnings are raised. + residual(root) + return root + + +def _design_capillary( + radius: float, + gas: Gas | str, + *, + temperature: float, + m: int, + model: MarcatiliModel, + cladding: GlassIndex | complex | None, + lattice: TubeLattice | None, + materials: MaterialSelection | None, +) -> MarcatiliMode: + """Build the single-fill capillary a pressure search re-evaluates. + + The mode `prop_capillary` builds with the same ``materials=``: the gas (or + ``"air"``) as an optmat fill at one bar and ``cladding=None`` as optmat's + recommended silica. + + - ``radius``: core radius in m, > 0. + - ``temperature``: fill temperature in K. + - ``cladding``: cladding index; ``None`` is fused silica. + - ``lattice``: tube geometry for a VincettiMode; None builds a Marcatili capillary. + """ + bore = positive(radius, "radius", ToolsError) + wall = _default_cladding(cladding) + # The fill is an optmat mixture, which carries its own pressure and temperature. + mixture = _resolve_fill(gas, 1.0, temperature, materials) + if lattice is None: + return MarcatiliMode( + bore, None, 0.0, m=m, model=model, cladding=wall, mixture=mixture + ) + return VincettiMode( + bore, + None, + 0.0, + m=m, + model=model, + cladding=wall, + mixture=mixture, + lattice=lattice, + ) + + +def _capillary_at(mode: MarcatiliMode, pressure: float) -> MarcatiliMode: + """Return a single-fill capillary re-evaluated at another pressure. + + The mode is built once and frozen at each density through its parameterised form, + so a root finder costs an equation-of-state call and an index evaluation per step + rather than a mode construction. A selected fill takes its density from its + species' own equation of state (optmat's); a selected air, two populations at + one shared pressure, is frozen at that pressure. + + - ``pressure``: pressure in bar. + """ + # pressure_rdw and pressure_zdw build this mode at their one scalar temperature. + temperature = cast("float", mode.temperature) + # `_design_capillary` always builds the mode on an optmat fill. + fill = cast("Mixture", mode.mixture) + if len(fill.components) > 1: + return mode.at_parameters({"pressure": float(pressure)}) + state = next(iter(fill.components)).species.equation_of_state + rho = float(state.density(float(pressure), temperature)) + return mode.at_parameters({"density": rho}) + + +def _search_grid( + low: float, high: float, samples: int = _SEARCH_SAMPLES +) -> Float[np.ndarray, " n_samples"]: + """Return the values a sampled root search evaluates its residual at. + + - Returns: `samples` linear values, and as many logarithmic ones where ``low > 0``, + merged and ascending; both ends are among them. + """ + # The logarithmic grid resolves the low end of a range spanning decades (a + # pressure from 1e-6 bar); the linear grid resolves structure at its high end. + # Pairs of roots, or admissible intervals, narrower than a sample are not + # guaranteed: narrowing the range resolves them. + linear = np.linspace(low, high, samples) + if low <= 0.0: + return linear + return np.unique(np.concatenate((np.geomspace(low, high, samples), linear))) + + +@dataclass(frozen=True) +class _Sampled: + """The outcome of a sampled root search. + + Attributes + ---------- + roots : tuple of float + The roots, ascending. + values : tuple of float + The sampled values of the parameter, ascending. + residuals : tuple of float or None + The residual at each sample; None where the target is undefined. + """ + + roots: tuple[float, ...] + values: tuple[float, ...] + residuals: tuple[float | None, ...] + + +def _sampled_roots( + residual: Callable[[float], float | None], + limits: tuple[float, float], + *, + what: str, + narrow: str, +) -> _Sampled: + """Find every root of a residual over a range, without crossing a pole. + + The residual is sampled on :func:`_search_grid`; each sign change between two + samples where it is defined is refined with Brent's method, and the refined + point is a root only if the residual there is below :data:`_ROOT_RESIDUAL` of + its larger value at the bracket's ends. A sample that is exactly zero is a root. + + - ``residual``: a function of the parameter returning a finite float, or None where + the target is undefined (an inadmissible state, such as no soliton). + """ + low, high = limits + grid = _search_grid(low, high) + # Brent's method re-evaluates the ends of its bracket and the root it returns, + # which are samples already: one evaluation per distinct value. + seen: dict[float, float | None] = {} + + def checked(value: float) -> float | None: + """Evaluate the residual once per value, refusing a value that is not finite. + + - Returns: finite residual, or None where the target is undefined. + """ + if value not in seen: + result = residual(value) + if result is not None and not math.isfinite(result): + raise ToolsError(f"{what} is not finite at {value:g}") + seen[value] = result + return seen[value] + + def refine(value: float) -> float: + """Evaluate the residual inside one bracket, where it must be defined.""" + result = checked(value) + if result is None: + # The only undefined state a target has is the largest constituent's + # absence, below the first soliton threshold or in normal dispersion. + raise ToolsError( + f"the refinement of {what} crossed a no-soliton region at " + f"{value:g}, between two samples outside it; narrow {narrow}" + ) + return result + + with _probing(): + sampled = [checked(float(value)) for value in grid] + roots = [ + float(value) + for value, result in zip(grid, sampled, strict=True) + if result == 0.0 + ] + for i, (left, right) in enumerate(pairwise(sampled)): + if left is None or right is None or left * right >= 0.0: + continue + start, stop = float(grid[i]), float(grid[i + 1]) + root = float( + brentq( + refine, + start, + stop, + xtol=_ROOT_TOLERANCE * max(abs(start), abs(stop)), + ) + ) + if abs(refine(root)) <= _ROOT_RESIDUAL * max(abs(left), abs(right)): + roots.append(root) + else: + logger.debug( + "discarded the sign change of %s at %g as a pole", what, root + ) + return _Sampled( + tuple(sorted(roots)), + tuple(float(value) for value in grid), + tuple(sampled), + ) + + +def _rdw_pressure_root( + residual: Callable[[float], float | None], limits: tuple[float, float] +) -> float: + """Find a unique pressure root without crossing a sampled inadmissible region. + + - ``residual``: mismatch in 1/m, or None only where no focusing discrete soliton + exists. + - ``limits``: positive ordered pressure bracket in bar. + - Returns: unique pressure root in bar. + """ + low, high = limits + if not (math.isfinite(low) and math.isfinite(high) and 0 < low < high): + raise ToolsError("pressure_limits must be finite, positive and ordered") + roots = _sampled_roots( + residual, limits, what="the RDW pressure residual", narrow="pressure_limits" + ).roots + if not roots: + raise ToolsError( + f"no pressure between {low:g} and {high:g} bar gives an admissible " + "RDW root; narrow pressure_limits if needed" + ) + root, *others = roots + if others: + found = ", ".join(f"{value:.4g}" for value in roots) + raise ToolsError( + f"ambiguous RDW pressure: {len(roots)} roots, at {found} bar; " + "choose one with pressure_limits" + ) + # Re-evaluate the accepted answer outside the probing warning filter. + residual(root) + return root + + +def pressure_rdw( + radius: float, + gas: Gas | str, + target_wavelength: float, + wavelength: float, + *, + pressure_max: float = 100.0, + peak_power: float | None = None, + fwhm: float | None = None, + shape: PulseShape = "sech", + nonlinear_phase: NonlinearPhase = "largest", + lattice: TubeLattice | None = None, + pressure_limits: tuple[float, float] | None = None, + temperature: float = ROOM_TEMPERATURE, + m: int = 1, + model: MarcatiliModel = "full", + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, +) -> float: + """Return the pressure that phase matches a dispersive wave at a target wavelength. + + Parameters + ---------- + radius : float + Core radius in m, > 0. + gas : Gas or str + A single built-in gas. + target_wavelength : float + Wavelength the dispersive wave should have, m. + wavelength : float + Pump wavelength in m. + pressure_max : float, optional + Upper end of the pressure bracket in **bar**; the lower end is 1e-6 bar. + peak_power : float or None, optional + Peak power in W. When given, the soliton's nonlinear wavenumber is included + and its nonlinear coefficient follows the pressure. + fwhm : float or None, optional + Positive input power FWHM in s, required with power for the default phase. + shape : {'sech', 'gauss'}, optional + Unchirped input shape. + nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional + Largest constituent by default; supplied power preserves gamma P0/2 + without fwhm. Linear requires omitting pulse numbers. + lattice : TubeLattice or None, optional + Tube geometry for VincettiMode; None builds a Marcatili capillary. + pressure_limits : tuple of float or None, optional + Positive ordered search range in bar, within (1e-6, pressure_max). + None uses that whole range. Searches 129 logarithmic and 129 linear + samples, refining admissible sign changes; sub-grid root pairs or valid + intervals can be missed. Narrow the range to resolve them. + temperature : float, optional + Fill temperature in K. + m : int, optional + Radial index of the HE_1m mode. + model : {'full', 'reduced'}, optional + Marcatili model. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Design the capillary `prop_capillary` builds under the same + ``materials=``: the gas from optmat (index, density, and the Kerr + coefficient at `wavelength` for the nonlinear phase) and ``cladding=None`` + as optmat's recommended fused silica. ``None`` is + ``MaterialSelection()``. + + Returns + ------- + float + Pressure in bar. + + Raises + ------ + ToolsError + If no pressure in the bracket matches, or the selection names a carrier + other than `wavelength`. + + Examples + -------- + >>> from solprop.tools import pressure_rdw + >>> f"{pressure_rdw(125e-6, 'He', 199.75e-9, 800e-9, cladding=1.45):.2f}" + '1.00' + """ + # Validate before scanning; the nonlinear coefficient follows the pressure. + design = RDW( + wavelength, + target_wavelength, + peak_power=peak_power, + fwhm=fwhm, + shape=shape, + nonlinear_phase=nonlinear_phase, + ) + top = positive(pressure_max, "pressure_max", ToolsError) + if top <= _PRESSURE_FLOOR: + raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") + limits = (_PRESSURE_FLOOR, top) if pressure_limits is None else pressure_limits + if limits[0] < _PRESSURE_FLOOR or limits[1] > top: + raise ToolsError("pressure_limits must lie within (1e-6, pressure_max) bar") + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + base_mode = _design_capillary( + radius, + gas, + temperature=temperature, + m=m, + model=model, + cladding=cladding, + lattice=lattice, + materials=materials, + ) + + def _residual(pressure: float) -> float | None: + """Return the mismatch at the target for one pressure. + + - ``pressure``: pressure in bar. + - Returns: mismatch in 1/m, or None when the constituent does not exist. + """ + mode = _capillary_at(base_mode, pressure) + gamma = None + if peak_power is not None: + assert mode.mixture is not None + index = _fill_n2(mode.mixture, wavelength)[0] + gamma = nonlinear_coefficient(mode, index, wavelength) + return _rdw_residual(design, mode, gamma, 0.0) + + return _rdw_pressure_root(_residual, limits) + + +def pressure_zdw( + radius: float, + gas: Gas | str, + zdw_wavelength: float, + *, + pressure_max: float = 100.0, + temperature: float = ROOM_TEMPERATURE, + m: int = 1, + model: MarcatiliModel = "full", + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, +) -> float: + """Return the pressure that puts the zero-dispersion point at a wavelength. + + Parameters + ---------- + radius : float + Core radius in m, > 0. + gas : Gas or str + A single built-in gas. + zdw_wavelength : float + Wavelength at which the dispersion should vanish, m. + pressure_max : float, optional + Upper end of the pressure bracket in **bar**; the lower end is 1e-6 bar. + temperature : float, optional + Fill temperature in K. + m : int, optional + Radial index of the HE_1m mode. + model : {'full', 'reduced'}, optional + Marcatili model. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Design the capillary `prop_capillary` builds under the same + ``materials=``: the gas's index and density from optmat and + ``cladding=None`` as optmat's recommended fused silica. ``None`` is + ``MaterialSelection()``. + + Returns + ------- + float + Pressure in bar. + + Raises + ------ + ToolsError + If no pressure in the bracket does. + + Examples + -------- + >>> from solprop.tools import pressure_zdw + >>> f"{pressure_zdw(125e-6, 'Ar', 500e-9, cladding=1.45):.3f}" + '0.066' + """ + materials = MaterialSelection() if materials is None else materials + base_mode = _design_capillary( + radius, + gas, + temperature=temperature, + m=m, + model=model, + cladding=cladding, + lattice=None, + materials=materials, + ) + omega = np.array([_omega(zdw_wavelength)]) + + def _residual(pressure: float) -> float: + """Return the dispersion at the wavelength for one pressure. + + - ``pressure``: pressure in bar. + - Returns: ``beta_2`` in s^2/m, scaled to order one. + """ + mode = _capillary_at(base_mode, pressure) + # Scaled so that brentq's absolute tolerance on the residual is meaningless + # and its tolerance on the pressure decides, as intended. + return float(base.beta2(mode, omega)[0]) * 1e30 + + return _pressure_root( + _residual, + pressure_max, + f"a zero-dispersion wavelength of {zdw_wavelength * 1e9:.4g} nm", + ) + + +@dataclass(frozen=True) +class ZDW: + r"""Target a zero-dispersion wavelength: :math:`\beta_2 = 0` at a wavelength. + + The residual :func:`solve_parameter` brackets is :math:`\beta_2` of the trial mode + at `wavelength`, in s^2/m. + + Attributes + ---------- + wavelength : float + Wavelength at which the dispersion should vanish, m, > 0. + + Raises + ------ + ToolsError + If the wavelength is not finite and positive. + + Examples + -------- + >>> from solprop.tools import ZDW + >>> ZDW(500e-9) + ZDW(wavelength=5e-07) + """ + + wavelength: float + + def __post_init__(self) -> None: + """Refuse a wavelength that is not finite and positive. + + Raises + ------ + ToolsError + If the wavelength is not finite and positive. + """ + positive(self.wavelength, "wavelength", ToolsError) + + +@dataclass(frozen=True) +class RDW: + """Target a resonant dispersive wave phase matched at a wavelength. + + The residual :func:`solve_parameter` brackets is :func:`phase_mismatch` of the + trial mode at `rdw`, in 1/m, exactly as :func:`pressure_rdw` evaluates it: with + `peak_power` the soliton's nonlinear wavenumber is included, with the trial + mode's own nonlinear coefficient, and with the largest constituent (`fwhm` + given) a trial value in normal dispersion or below the first soliton threshold + has no residual and cannot bracket a root. + + Attributes + ---------- + pump : float + Wavelength of the soliton, m, > 0. + rdw : float + Wavelength the dispersive wave should have, m, > 0 and not the pump's. + peak_power : float or None + Input peak power in W for the nonlinear term; ``None`` is the linear + condition. With it, the builder returns ``(mode, gamma)``. + fwhm : float or None + Input power FWHM in s; required with `peak_power` for the default phase. + shape : {'sech', 'gauss'} + Unchirped input shape for the largest constituent. + nonlinear_phase : {'largest', 'supplied_power', 'linear'} + The nonlinear phase model, as :func:`phase_mismatch` takes it. + + Raises + ------ + ToolsError + If a wavelength is not positive, the two are equal, or the pulse numbers + are incomplete or contradictory for the phase model. + + Examples + -------- + >>> from solprop.tools import RDW + >>> RDW(800e-9, 200e-9, peak_power=2e9, fwhm=10e-15).nonlinear_phase + 'largest' + """ + + pump: float + rdw: float + peak_power: float | None = None + fwhm: float | None = None + shape: PulseShape = "sech" + nonlinear_phase: NonlinearPhase = "largest" + + def __post_init__(self) -> None: + """Refuse wavelengths or pulse numbers the phase matching does not define. + + Raises + ------ + ToolsError + If a wavelength is not positive, the two are equal, or the pulse + numbers are incomplete or contradictory. + """ + # A placeholder gamma: the real one comes with each trial mode. + _PhasePulse( + self.peak_power, + None if self.peak_power is None else 1.0, + self.fwhm, + self.shape, + self.nonlinear_phase, + ) + positive(self.pump, "pump", ToolsError) + positive(self.rdw, "rdw", ToolsError) + if self.rdw == self.pump: + # The linear mismatch and its slope vanish at the pump for every mode. + raise ToolsError( + f"the dispersive wave cannot be at the pump wavelength, {self.pump:g} m" + ) + + +@dataclass(frozen=True) +class SolitonOrder: + """Target a conventional soliton order for a fixed pulse. + + The residual :func:`solve_parameter` brackets is :func:`soliton_order` of the + pulse in the trial mode, with the trial mode's own nonlinear coefficient, minus + `order`: a guide or fill parameter is solved for, the pulse is held. The order + is the conventional measure on either side of a zero-dispersion point, so a + search across one may find a root on each side. The opposite question, the + pulse that has an order on a given mode, is closed form: + :func:`soliton_peak_power` and :func:`pulse_energy`. + + Attributes + ---------- + order : float + Conventional soliton order, > 0. + pulse : GaussianPulse or SechPulse + The pulse: its wavelength is the pump, its FWHM and its energy or peak power + are held, and its class sets the shape. An ordinary Gaussian (``m = 1``) + with no spectral phase beyond a carrier-envelope phase and a delay. + + Raises + ------ + ToolsError + If the order is not positive, the Gaussian is super-Gaussian, or the pulse + is chirped. + + Examples + -------- + >>> from solprop.pulses import SechPulse + >>> from solprop.tools import SolitonOrder + >>> target = SolitonOrder(3.0, SechPulse(wavelength=800e-9, fwhm=10e-15, + ... energy=100e-6)) + >>> f"{target.peak_power / 1e9:.3f} GW" + '8.814 GW' + """ + + order: float + pulse: GaussianPulse | SechPulse + + def __post_init__(self) -> None: + """Refuse an order or a pulse the design numbers are not defined for. + + Raises + ------ + ToolsError + If the order is not positive, the Gaussian is super-Gaussian, or the + pulse is chirped. + """ + positive(self.order, "order", ToolsError) + if isinstance(self.pulse, GaussianPulse) and self.pulse.m != 1: + raise ToolsError( + "the soliton order is defined for sech and Gaussian pulses, and this " + f"is a super-Gaussian of order m={self.pulse.m}" + ) + if np.any(np.asarray(self.pulse.phase, dtype=float)[2:] != 0.0): + # The design numbers take the width and peak power of an unchirped pulse. + raise ToolsError( + "the soliton order is defined for an unchirped pulse; this one has " + "spectral phase beyond the carrier-envelope phase and the delay" + ) + + @property + def shape(self) -> PulseShape: # numpydoc ignore=RT01 + """Pulse shape the design numbers use. + + ``"gauss"`` for a `GaussianPulse`, ``"sech"`` for a `SechPulse`. + """ + return "gauss" if isinstance(self.pulse, GaussianPulse) else "sech" + + @property + def fwhm(self) -> float: # numpydoc ignore=RT01 + """The pulse's full width at half maximum of the power, s. + + A `SechPulse` given its natural width converts it. + """ + pulse = self.pulse + if isinstance(pulse, GaussianPulse): + return pulse.fwhm + # SechPulse holds exactly one of the two widths (its own validation). + return ( + pulse.fwhm + if pulse.fwhm is not None + else cast("float", pulse.width) * _SECH_WIDTH + ) + + @property + def peak_power(self) -> float: # numpydoc ignore=RT01 + """The pulse's peak power, W. + + The pulse's own, or that of its energy (:func:`pulse_peak_power`). + """ + if self.pulse.peak_power is not None: + return self.pulse.peak_power + # ShapedPulse holds exactly one of energy and peak power. + return pulse_peak_power(cast("float", self.pulse.energy), self.fwhm, self.shape) + + +DesignTarget = ZDW | RDW | SolitonOrder +"""A target of :func:`solve_parameter`.""" + + +RootChoice = Literal["lowest", "highest"] | int +"""Which of several roots: the lowest, the highest, or an index into them.""" + + +DesignBuild = Callable[[float], Mode | tuple[Mode, float]] +"""A builder: a trial value of the parameter to a mode, or to a mode and the fill's +nonlinear coefficient in rad/(W m) (:func:`nonlinear_coefficient`).""" + + +@dataclass(frozen=True) +class DesignRoots: + """Every root of a design target over a parameter's search range. + + Iterating gives the roots, and its length is their number. + + Attributes + ---------- + roots : tuple of float + The values of the parameter that meet the target, ascending, in the + parameter's unit; empty when none does. + search : tuple of float + The search range, ``(low, high)``. + target : ZDW, RDW or SolitonOrder + The target. + builds : int + How many times the builder was called, the search's cost. + quantity : str + What `attainable` measures, with its unit: the zero-dispersion wavelength + nearest the target's (m), the phase-matched wavelength nearest the target's + (m), or the soliton order. + attainable : tuple of float or None + When there is no root, the smallest and largest `quantity` on a coarser grid + of the search range (17 linear and, where ``low > 0``, 17 logarithmic + values, the ends included), where it is defined: a mode with no zero + dispersion in 100 nm to 3 um, or with no phase-matched wavelength in the + RDW window or no soliton, has none. ``None`` when there are roots, or no + value defines it. + + Examples + -------- + >>> from solprop.tools import DesignRoots, ZDW + >>> roots = DesignRoots((1.0, 2.0), (0.5, 3.0), ZDW(5e-7), 260, + ... "zero-dispersion wavelength in m") + >>> len(roots), roots.pick("highest"), roots.pick(0) + (2, 2.0, 1.0) + """ + + roots: tuple[float, ...] + search: tuple[float, float] + target: DesignTarget + builds: int + quantity: str + attainable: tuple[float, float] | None = None + + def __len__(self) -> int: + """Return the number of roots. + + Returns + ------- + int + The number of roots. + """ + return len(self.roots) + + def __iter__(self) -> Iterator[float]: + """Iterate over the roots, ascending. + + Returns + ------- + Iterator + The roots. + """ + return iter(self.roots) + + def pick(self, root: RootChoice = "lowest") -> float: + """Return one root. + + Parameters + ---------- + root : {'lowest', 'highest'} or int, optional + The lowest or highest root, or an index into the ascending roots + (negative from the end, as a sequence's). + + Returns + ------- + float + The root, in the parameter's unit. + + Raises + ------ + ToolsError + If there is no root, naming the attainable range, or the index is out of + range, naming the roots. + + Examples + -------- + >>> from solprop.tools import DesignRoots, ZDW + >>> DesignRoots((1.0, 2.0), (0.5, 3.0), ZDW(5e-7), 260, "").pick(-1) + 2.0 + """ + if not self.roots: + raise ToolsError(self._unreachable()) + if root == "lowest": + return self.roots[0] + if root == "highest": + return self.roots[-1] + if isinstance(root, bool) or not isinstance(root, int): + raise ToolsError( + f"root must be 'lowest', 'highest' or an index, got {root!r}" + ) + count = len(self.roots) + if not -count <= root < count: + found = ", ".join(f"{value:.6g}" for value in self.roots) + raise ToolsError( + f"root index {root} is out of range: {count} root" + f"{'' if count == 1 else 's'}, at {found}" + ) + return self.roots[root] + + def _unreachable(self) -> str: + """Return why no root was found.""" + low, high = self.search + if self.attainable is None: + seen = f"the {self.quantity} is undefined across it" + else: + seen = ( + f"across it the {self.quantity} spans " + f"{self.attainable[0]:.6g} to {self.attainable[1]:.6g}" + ) + return ( + f"no value between {low:.6g} and {high:.6g} gives " + f"{_describe(self.target)}: {seen}" + ) + + +def _describe(target: DesignTarget) -> str: + """Return a target in words, for messages. + + - Returns: for example ``"a zero-dispersion wavelength of 500 nm"``. + """ + if isinstance(target, ZDW): + return f"a zero-dispersion wavelength of {target.wavelength * 1e9:.6g} nm" + if isinstance(target, RDW): + return ( + f"a dispersive wave at {target.rdw * 1e9:.6g} nm from the pump at " + f"{target.pump * 1e9:.6g} nm" + ) + return f"a soliton order of {target.order:.6g}" + + +def _quantity_name(target: DesignTarget) -> str: + """Return what a target's attainable range measures, with its unit.""" + if isinstance(target, ZDW): + return "zero-dispersion wavelength nearest the target (m)" + if isinstance(target, RDW): + return "phase-matched wavelength nearest the target (m)" + return "soliton order" + + +def _needs_gamma(target: DesignTarget) -> bool: + """Return whether a target needs the fill's nonlinear coefficient.""" + if isinstance(target, RDW): + return target.peak_power is not None + return isinstance(target, SolitonOrder) + + +def _pump_dispersion(mode: Mode, wavelength: float, z: float) -> float: + """Return the group-velocity dispersion at a pump, refusing one not finite. + + - ``wavelength``: pump wavelength in m. + - ``z``: position along the waveguide, m. + - Returns: ``beta_2`` in s^2/m. + """ + dispersion = float(base.beta2(mode, np.array([_omega(wavelength)]), z)[0]) + if not math.isfinite(dispersion): + raise ToolsError( + f"the pump dispersion is not finite at {wavelength * 1e9:.6g} nm in a " + "trial mode" + ) + return dispersion + + +def _rdw_residual( + target: RDW, mode: Mode, gamma: float | None, z: float +) -> float | None: + """Return the phase mismatch at a dispersive-wave target, as the RDW search uses it. + + - ``gamma``: the mode's nonlinear coefficient in rad/(W m); given with a peak power. + - ``z``: position along the waveguide, m. + - Returns: mismatch in 1/m, or None when the largest constituent does not exist: in + normal dispersion, or at a pulse area at or below the first threshold. + """ + if target.peak_power is not None and target.fwhm is not None: + dispersion = _pump_dispersion(mode, target.pump, z) + if dispersion >= 0: + return None + order = soliton_order( + target.peak_power, + target.fwhm, + cast("float", gamma), + dispersion, + target.shape, + ) + if order * _area_per_order(target.shape) <= 0.5: + return None + return float( + phase_mismatch( + mode, + np.array([_omega(target.rdw)]), + target.pump, + z=z, + peak_power=target.peak_power, + gamma=gamma, + fwhm=target.fwhm, + shape=target.shape, + nonlinear_phase=target.nonlinear_phase, + )[0] + ) + + +def _target_residual( + target: DesignTarget, mode: Mode, gamma: float | None, z: float +) -> float | None: + """Return a target's residual on one trial mode. + + - ``gamma``: its nonlinear coefficient in rad/(W m), where the target needs one. + - ``z``: position along the waveguide, m. + - Returns: ``beta_2`` in s^2/m, the mismatch in 1/m (None where no soliton exists), + or the soliton order less the target's. + """ + if isinstance(target, ZDW): + return float(base.beta2(mode, np.array([_omega(target.wavelength)]), z)[0]) + if isinstance(target, RDW): + return _rdw_residual(target, mode, gamma, z) + dispersion = _pump_dispersion(mode, target.pulse.wavelength, z) + order = soliton_order( + target.peak_power, target.fwhm, cast("float", gamma), dispersion, target.shape + ) + return order - target.order + + +def _rdw_window(target: RDW) -> tuple[float, float]: + """Return the window an RDW target's attainable wavelengths are searched in. + + - Returns: :func:`lambda_rdw`'s default window, 100 nm to 0.9 of the pump, when the + target is on the blue side of the pump, widened to include it; and the mirror of + it, 1/0.9 of the pump to 3 um, on the red side. + """ + if target.rdw < target.pump: + return (min(_ZDW_RANGE[0], 0.9 * target.rdw), 0.9 * target.pump) + return (target.pump / 0.9, max(_ZDW_RANGE[1], target.rdw / 0.9)) + + +def _target_quantity( + target: DesignTarget, mode: Mode, gamma: float | None, z: float +) -> float: + """Return what a target measures on one trial mode, for the attainable range. + + - ``gamma``: its nonlinear coefficient in rad/(W m), where the target needs one. + - ``z``: position along the waveguide, m. + - Returns: the zero-dispersion or phase-matched wavelength nearest the target's in + m, or the soliton order; NaN where the mode has none. + """ + if isinstance(target, ZDW): + return _zdw_near(mode, _omega(target.wavelength), z) + if isinstance(target, SolitonOrder): + residual = _target_residual(target, mode, gamma, z) + return target.order + cast("float", residual) + if _rdw_residual(target, mode, gamma, z) is None: + return math.nan + try: + roots = lambda_rdw_all( + mode, + target.pump, + z=z, + peak_power=target.peak_power, + gamma=gamma, + fwhm=target.fwhm, + shape=target.shape, + nonlinear_phase=target.nonlinear_phase, + wavelength_limits=_rdw_window(target), + ) + except ToolsError: + # The window reaches beyond the mode's index model; this is a diagnostic + # for a target already found unreachable, so the sample has no wavelength. + return math.nan + if not roots: + return math.nan + return min(roots, key=lambda root: abs(root - target.rdw)) + + +def _built( + build: DesignBuild, value: float, target: DesignTarget +) -> tuple[Mode, float | None]: + """Call a builder and check what it returned. + + - Returns: the mode, and the nonlinear coefficient in rad/(W m), or None where the + target does not use one. + """ + result = build(value) + if isinstance(result, tuple): + if len(result) != 2: + raise ToolsError( + f"build({value:g}) returned a tuple of {len(result)}; give a mode " + "or (mode, gamma)" + ) + mode, gamma = result + gamma = positive(gamma, "gamma", ToolsError) + else: + mode, gamma = result, None + if not isinstance(mode, Mode): + raise ToolsError( + f"build({value:g}) returned {type(mode).__name__}, not a mode or " + "(mode, gamma)" + ) + if not _needs_gamma(target): + # One builder serves every target: a linear target ignores the coefficient. + return mode, None + if gamma is None: + raise ToolsError( + f"{_describe(target)} needs the fill's nonlinear coefficient: build " + "returns (mode, gamma), with gamma from nonlinear_coefficient" + ) + return mode, gamma + + +def solve_parameter( + build: DesignBuild, + search: tuple[float, float], + target: DesignTarget, + *, + z: float = 0.0, +) -> DesignRoots: + """Return every value of a guide or fill parameter that meets a design target. + + The general form of :func:`pressure_zdw`, :func:`pressure_rdw` and + :func:`radius_zdw`, for any mode and any parameter: a radius, a pressure, a core + size or a wall thickness. The target's residual is sampled over `search` on 129 + linear values and, where ``low > 0``, 129 logarithmic ones (as + :func:`pressure_rdw` samples pressure); each sign change between two samples is + refined with Brent's method to a relative tolerance of 2e-12; and a refined + change is kept as a root only where the residual has fallen to a thousandth of + its value at the bracket's ends, which discards a change through a pole (a wall + resonance, where it diverges instead). Roots closer together than one sample + are not separated: narrow `search` to resolve them. + + Parameters + ---------- + build : callable + ``build(value)`` returns the mode at a trial value of the parameter, or + ``(mode, gamma)`` with the fill's nonlinear coefficient in rad/(W m) + (:func:`nonlinear_coefficient`), which an RDW target with a peak power and a + soliton-order target need: a mode need not know its fill's Kerr response. + search : tuple of float + Finite ordered range of the parameter, ``(low, high)``, in its unit. + target : ZDW, RDW or SolitonOrder + What the parameter should achieve. + z : float, optional + Position along the waveguide at which each mode is evaluated, m: the launch + position. + + Returns + ------- + DesignRoots + Every root, ascending, with the attainable range of the target's quantity + when there is none; ``.pick(root)`` chooses one. + + Raises + ------ + ToolsError + If the range or position is not finite or not ordered, the builder returns + something other than a mode (and a coefficient where one is needed), a + residual is not finite, or a refinement crosses a no-soliton region. + + Examples + -------- + The radius of a helium capillary at 1 bar with its zero dispersion at 400 nm, + re-evaluating one mode at each trial radius: + + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import ZDW, solve_parameter + >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) + >>> roots = solve_parameter( + ... lambda radius: mode.at_parameters({"core_radius": radius}), + ... (20e-6, 500e-6), + ... ZDW(400e-9), + ... ) + >>> [f"{radius * 1e6:.2f} um" for radius in roots] + ['89.49 um'] + """ + low, high = (float(value) for value in search) + if not (math.isfinite(low) and math.isfinite(high) and low < high): + raise ToolsError(f"search must be finite and ordered, got {search!r}") + if not math.isfinite(z): + raise ToolsError(f"z must be finite, got {z!r}") + builds = 0 + + def _residual(value: float) -> float | None: + """Build the mode at one value and return the target's residual. + + - Returns: the residual, or None where the target is undefined. + """ + nonlocal builds + builds += 1 + mode, gamma = _built(build, value, target) + return _target_residual(target, mode, gamma, z) + + sampled = _sampled_roots( + _residual, (low, high), what="the target's residual", narrow="search" + ) + # Outside the probing context, so a root beyond an index model's range warns. + for root in sampled.roots: + _residual(root) + attainable = None + if not sampled.roots: + # The modes are rebuilt rather than kept from the search, on a coarser grid: + # a tabulated mode can be large, and only a search without a root pays. + with _probing(): + values = [] + for value in _search_grid(low, high, _ATTAINABLE_SAMPLES): + builds += 1 + mode, gamma = _built(build, float(value), target) + values.append(_target_quantity(target, mode, gamma, z)) + finite = [value for value in values if math.isfinite(value)] + if finite: + attainable = (min(finite), max(finite)) + return DesignRoots( + roots=sampled.roots, + search=(low, high), + target=target, + builds=builds, + quantity=_quantity_name(target), + attainable=attainable, + ) + + +def radius_zdw( + gas: Gas | str, + pressure: float, + zdw_wavelength: float, + *, + radius_limits: tuple[float, float] = (5e-6, 1e-3), + temperature: float = ROOM_TEMPERATURE, + m: int = 1, + model: MarcatiliModel = "full", + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, + root: RootChoice = "lowest", +) -> float: + """Return the core radius that puts a capillary's zero dispersion at a wavelength. + + The radius counterpart of :func:`pressure_zdw`, through + :func:`solve_parameter`: the capillary is built once at `pressure` and + re-evaluated at each trial radius. + + Parameters + ---------- + gas : Gas or str + A single built-in gas. + pressure : float + Fill pressure in **bar**, > 0. + zdw_wavelength : float + Wavelength at which the dispersion should vanish, m. + radius_limits : tuple of float, optional + Positive ordered search range of the core radius, m. + temperature : float, optional + Fill temperature in K. + m : int, optional + Radial index of the HE_1m mode. + model : {'full', 'reduced'}, optional + Marcatili model. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Design the capillary `prop_capillary` builds under the same + ``materials=``, as :func:`pressure_zdw` does. ``None`` is + ``MaterialSelection()``. + root : {'lowest', 'highest'} or int, optional + Which root, should there be several (``DesignRoots.pick``). + + Returns + ------- + float + Core radius in m. + + Raises + ------ + ToolsError + If the radius limits are not positive and ordered, the pressure is not + positive, or no radius in the range gives the zero (naming the range of + zero-dispersion wavelengths it does give). + + Examples + -------- + >>> from solprop.tools import radius_zdw + >>> f"{radius_zdw('He', 1.0, 400e-9, cladding=1.45) * 1e6:.2f} um" + '89.49 um' + """ + low, high = radius_limits + if not (math.isfinite(low) and math.isfinite(high) and 0.0 < low < high): + raise ToolsError( + f"radius_limits must be finite, positive and ordered, got {radius_limits!r}" + ) + materials = MaterialSelection() if materials is None else materials + capillary = _capillary_at( + _design_capillary( + high, + gas, + temperature=temperature, + m=m, + model=model, + cladding=cladding, + lattice=None, + materials=materials, + ), + positive(pressure, "pressure", ToolsError), + ) + roots = solve_parameter( + lambda radius: capillary.at_parameters({"core_radius": radius}), + (low, high), + ZDW(zdw_wavelength), + ) + return roots.pick(root) diff --git a/src/solprop/tools/phase_matching.py b/src/solprop/tools/phase_matching.py new file mode 100644 index 00000000..e9fd55b2 --- /dev/null +++ b/src/solprop/tools/phase_matching.py @@ -0,0 +1,466 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Dispersive-wave phase matching: the mismatch and its roots in wavelength.""" + +import math +from dataclasses import dataclass +from typing import Literal, get_args + +import numpy as np +from jaxtyping import Float +from scipy.optimize import brentq + +from solprop._validation import choice, positive +from solprop.constants import C +from solprop.exceptions import ( + ToolsError, +) +from solprop.modes import base +from solprop.modes.protocol import Mode +from solprop.tools.pulse import PulseShape, _omega +from solprop.tools.soliton import soliton_content +from solprop.tools.waveguide import _probing + +__all__ = ["NonlinearPhase", "lambda_rdw", "lambda_rdw_all", "phase_mismatch"] + +NonlinearPhase = Literal["largest", "supplied_power", "linear"] +"""Choose the constituent, supplied-power or linear RDW phase model.""" + + +_MISMATCH_SAMPLES = 2048 +"""Frequency samples the phase-matching search brackets its roots on. + +The mismatch of a hollow waveguide is smooth on the scale of the search window (its +structure is set by the zero-dispersion points, of which there are a handful), so two +roots closer than a two-thousandth of the window are not separated. The cost is one +vectorised index evaluation. +""" + + +@dataclass(frozen=True) +class _PhasePulse: + """Validate the pulse declaration before a phase-matching search.""" + + peak_power: float | None + gamma: float | None + fwhm: float | None + shape: PulseShape + nonlinear_phase: NonlinearPhase + + def __post_init__(self) -> None: + """Refuse incomplete or contradictory phase declarations.""" + choice(self.shape, "shape", get_args(PulseShape), ToolsError) + choice( + self.nonlinear_phase, + "nonlinear_phase", + get_args(NonlinearPhase), + ToolsError, + ) + if (self.peak_power is None) != (self.gamma is None): + raise ToolsError( + "the nonlinear term needs both peak_power and gamma; " + "give both or neither" + ) + if self.nonlinear_phase == "linear" and ( + self.peak_power is not None or self.fwhm is not None + ): + raise ToolsError( + "nonlinear_phase='linear' requires omitting pulse power, gamma and fwhm" + ) + if self.peak_power is None: + if self.fwhm is not None: + raise ToolsError("fwhm requires peak_power and gamma") + return + positive(self.peak_power, "peak_power", ToolsError) + assert self.gamma is not None # The paired declaration was validated above. + positive(self.gamma, "gamma", ToolsError) + if self.nonlinear_phase == "largest": + if self.fwhm is None: + raise ToolsError( + "the largest constituent needs fwhm; supply it or select " + "nonlinear_phase='supplied_power'" + ) + positive(self.fwhm, "fwhm", ToolsError) + elif self.fwhm is not None: + raise ToolsError( + "nonlinear_phase='supplied_power' does not use fwhm; omit it" + ) + + def wavenumber(self, mode: Mode, omega: float, z: float) -> float: + """Calculate the nonlinear phase in the soliton mode. + + - ``omega``: pump angular frequency in rad/s. + - ``z``: position in m. + - Returns: nonlinear wavenumber in 1/m. + """ + if self.peak_power is None or self.gamma is None: + return 0.0 + if self.fwhm is None: + return 0.5 * self.gamma * self.peak_power + dispersion = float(base.beta2(mode, np.array([omega]), z)[0]) + content = soliton_content( + self.peak_power, self.fwhm, self.gamma, dispersion, self.shape + ) + if content.largest_nonlinear_wavenumber is None: + raise ToolsError( + "the pulse has no discrete soliton for " + "largest-constituent phase matching" + ) + return content.largest_nonlinear_wavenumber + + +@dataclass(frozen=True) +class _PhaseMismatch: + """Evaluate a mismatch with the pump and nonlinear phase prepared once.""" + + mode: Mode + omega0: float + beta0: float + slope: float + nonlinear: float + z: float + + def __call__( + self, omega: Float[np.ndarray, " n_omega"] + ) -> Float[np.ndarray, " n_omega"]: + """Evaluate the mismatch at trial frequencies. + + - ``omega``: angular frequencies in rad/s. + - Returns: mismatch in 1/m. + """ + return np.asarray( + base.beta(self.mode, omega, self.z) + - self.beta0 + - self.slope * (omega - self.omega0) + - self.nonlinear + ) + + +def _prepare_mismatch( + mode: Mode, + wavelength: float, + soliton_mode: Mode | None, + z: float, + pulse: _PhasePulse, +) -> _PhaseMismatch: + """Prepare a phase-matching calculation at one position. + + - ``wavelength``: pump wavelength in m. + - ``soliton_mode``: pump mode, or None to use mode. + - ``z``: position in m. + """ + soliton = mode if soliton_mode is None else soliton_mode + omega0 = _omega(wavelength) + pump = np.array([omega0]) + return _PhaseMismatch( + mode, + omega0, + float(base.beta(soliton, pump, z)[0]), + float(base.beta1(soliton, pump, z)[0]), + pulse.wavenumber(soliton, omega0, z), + z, + ) + + +def phase_mismatch( + mode: Mode, + omega: Float[np.ndarray, " n_omega"], + wavelength: float, + *, + soliton_mode: Mode | None = None, + z: float = 0.0, + peak_power: float | None = None, + gamma: float | None = None, + fwhm: float | None = None, + shape: PulseShape = "sech", + nonlinear_phase: NonlinearPhase = "largest", +) -> Float[np.ndarray, " n_omega"]: + r"""Return the phase mismatch between a soliton and a dispersive wave. + + .. math:: + \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) + - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} . + + Parameters + ---------- + mode : Mode + The mode the dispersive wave is emitted into. + omega : numpy.ndarray + Angular frequencies in rad/s, shape ``(n_omega,)``. + wavelength : float + Wavelength of the soliton in m, > 0. + soliton_mode : Mode or None, optional + The mode the soliton travels in; ``None`` is `mode`. A different one gives + intermodal phase matching. + z : float, optional + Position along the waveguide, m. + peak_power : float or None, optional + Input peak power in W. With `gamma` and `fwhm`, adds the soliton's nonlinear + wavenumber; both ``None`` (the default) gives the linear condition. + gamma : float or None, optional + Nonlinear coefficient of the soliton's mode in rad/(W m) + (:func:`nonlinear_coefficient`). + fwhm : float or None, optional + Input power FWHM in s, positive; required with power for the default phase. + shape : {'sech', 'gauss'}, optional + Unchirped input shape for the largest constituent. + nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional + Default uses the largest constituent. Supplied power uses gamma P0/2 and + requires omitting fwhm. Linear requires omitting all pulse numbers. + Without pulse numbers the default is also linear. + + Returns + ------- + numpy.ndarray + Mismatch in 1/m, shape ``(n_omega,)``. It vanishes at the pump in the linear + case, with zero slope there. + + Raises + ------ + ToolsError + If pulse arguments are incomplete or contradictory, or the largest + constituent does not exist in anomalous dispersion. + + Notes + ----- + The default nonlinear term is gamma times the largest constituent's power / 2 + (:func:`soliton_content`). The supplied-power option treats the quoted power as + a fundamental soliton, advancing as exp(i gamma P0 z / 2) (Akhmediev and + Karlsson, Phys. Rev. A 51, 2602 (1995)). Both are local estimates; loss, + higher-order dispersion, Raman, chirp and plasma evolution are not included. + + Examples + -------- + >>> import numpy as np + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import phase_mismatch + >>> mode = MarcatiliMode(125e-6, "He", 1.0) + >>> pump = 2 * np.pi * 299792458.0 / 800e-9 + >>> abs(float(phase_mismatch(mode, np.array([pump]), 800e-9)[0])) < 1e-6 + True + """ + pulse = _PhasePulse(peak_power, gamma, fwhm, shape, nonlinear_phase) + prepared = _prepare_mismatch(mode, wavelength, soliton_mode, z, pulse) + return prepared(np.asarray(omega, dtype=float)) + + +def lambda_rdw_all( + mode: Mode, + wavelength: float, + *, + soliton_mode: Mode | None = None, + z: float = 0.0, + peak_power: float | None = None, + gamma: float | None = None, + fwhm: float | None = None, + shape: PulseShape = "sech", + nonlinear_phase: NonlinearPhase = "largest", + wavelength_limits: tuple[float, float] | None = None, +) -> tuple[float, ...]: + """Return every phase-matched wavelength in a window, shortest first. + + Parameters + ---------- + mode : Mode + The mode the dispersive wave is emitted into. + wavelength : float + Wavelength of the soliton in m, > 0. + soliton_mode : Mode or None, optional + The soliton's mode; ``None`` is `mode`. + z : float, optional + Position along the waveguide, m. + peak_power : float or None, optional + Peak power in W for the nonlinear term; see :func:`phase_mismatch`. + gamma : float or None, optional + Nonlinear coefficient in rad/(W m) for the nonlinear term. + fwhm : float or None, optional + Input power FWHM in s, positive; required with power for the default phase. + shape : {'sech', 'gauss'}, optional + Unchirped input shape for the largest constituent. + nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional + Default uses the largest constituent. Supplied power uses gamma P0/2 and + requires omitting fwhm. Linear requires omitting all pulse numbers. + Without pulse numbers the default is also linear. + wavelength_limits : tuple of float or None, optional + Shortest and longest wavelength searched, m. ``None`` is 100 nm to 0.9 of the + pump wavelength, which keeps the double root at the pump out. + + Returns + ------- + tuple of float + The roots in m, ascending; empty when there is none. The mismatch is sampled + on 2048 frequencies across the window, each sign change is refined with + Brent's method, and roots closer than one sample are not separated. A sign + change through a resonance of the guide, where the mismatch has a pole or is + not a number, is not a root and is skipped. + + Raises + ------ + ToolsError + If the window is not positive and ordered. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import lambda_rdw_all + >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) + >>> [f"{root * 1e9:.0f}" for root in lambda_rdw_all(mode, 800e-9)] + ['200'] + """ + pulse = _PhasePulse(peak_power, gamma, fwhm, shape, nonlinear_phase) + prepared = _prepare_mismatch(mode, wavelength, soliton_mode, z, pulse) + limits = ( + (100e-9, 0.9 * wavelength) if wavelength_limits is None else wavelength_limits + ) + shortest, longest = limits + if not 0.0 < shortest < longest: + raise ToolsError( + f"the wavelength limits must be positive and ordered: {limits}" + ) + grid = np.linspace( + 2.0 * math.pi * C / longest, 2.0 * math.pi * C / shortest, _MISMATCH_SAMPLES + ) + + def _mismatch(value: float) -> float: + """Return the mismatch at one frequency, for the root finder. + + - ``value``: angular frequency in rad/s. + - Returns: mismatch in 1/m. + """ + return float(prepared(np.array([value]))[0]) + + with _probing(): + sampled = prepared(grid) + if not np.all(np.isfinite(sampled)): + # A sample that is not a number compares false with everything, so it + # would delete any root beside it without a word. + missing = int(np.sum(~np.isfinite(sampled))) + raise ToolsError( + f"the phase mismatch is not finite at {missing} of {sampled.size} " + f"samples in {shortest * 1e9:.4g}-{longest * 1e9:.4g} nm: the index " + "model does not cover the window" + ) + signs = np.sign(sampled) + found = [float(grid[i]) for i in np.flatnonzero(signs == 0.0)] + for i in np.flatnonzero(signs[:-1] * signs[1:] < 0.0): + try: + # The refinement may land on a resonance exactly, where a model may + # divide by zero: such a point is no root, and is told apart below. + with np.errstate(divide="ignore", invalid="ignore"): + root = float(brentq(_mismatch, float(grid[i]), float(grid[i + 1]))) + except ValueError: + # The index model is not a number inside the bracket: a resonance of + # the guide (an antiresonant fibre's), not a phase-matched wavelength. + continue + with np.errstate(divide="ignore", invalid="ignore"): + residual = _mismatch(root) + if not abs(residual) <= min(abs(sampled[i]), abs(sampled[i + 1])): + # Refined onto a pole, through which the sign changed: no root. + continue + found.append(root) + if found: + # Outside the probing context, so a root beyond the index model's range warns. + base.beta(mode, np.array(sorted(found)), z) + return tuple(sorted(2.0 * math.pi * C / root for root in found)) + + +def lambda_rdw( + mode: Mode, + wavelength: float, + *, + soliton_mode: Mode | None = None, + z: float = 0.0, + peak_power: float | None = None, + gamma: float | None = None, + fwhm: float | None = None, + shape: PulseShape = "sech", + nonlinear_phase: NonlinearPhase = "largest", + wavelength_limits: tuple[float, float] | None = None, +) -> float: + """Return the phase-matched dispersive-wave wavelength nearest the pump. + + Parameters + ---------- + mode : Mode + The mode the dispersive wave is emitted into. + wavelength : float + Wavelength of the soliton in m, > 0. + soliton_mode : Mode or None, optional + The soliton's mode; ``None`` is `mode`. + z : float, optional + Position along the waveguide, m. + peak_power : float or None, optional + Peak power in W for the nonlinear term; see :func:`phase_mismatch`. + gamma : float or None, optional + Nonlinear coefficient in rad/(W m) for the nonlinear term. + fwhm : float or None, optional + Input power FWHM in s, positive; required with power for the default phase. + shape : {'sech', 'gauss'}, optional + Unchirped input shape for the largest constituent. + nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional + Default uses the largest constituent. Supplied power uses gamma P0/2 and + requires omitting fwhm. Linear requires omitting all pulse numbers. + Without pulse numbers the default is also linear. + wavelength_limits : tuple of float or None, optional + Window searched, m; ``None`` is 100 nm to 0.9 of the pump wavelength. + + Returns + ------- + float + The longest root inside the window in m, which with the default window is the + short-wavelength dispersive wave nearest the pump. :func:`lambda_rdw_all` + returns all of them. + + Raises + ------ + ToolsError + If the window holds no root, rather than returning a missing value that would + propagate silently. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import lambda_rdw + >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) + >>> f"{lambda_rdw(mode, 800e-9) * 1e9:.1f}" + '200.1' + """ + roots = lambda_rdw_all( + mode, + wavelength, + soliton_mode=soliton_mode, + z=z, + peak_power=peak_power, + gamma=gamma, + fwhm=fwhm, + shape=shape, + nonlinear_phase=nonlinear_phase, + wavelength_limits=wavelength_limits, + ) + if not roots: + limits = ( + (100e-9, 0.9 * wavelength) + if wavelength_limits is None + else wavelength_limits + ) + ends = phase_mismatch( + mode, + 2.0 * math.pi * C / np.array(limits[::-1]), + wavelength, + soliton_mode=soliton_mode, + z=z, + peak_power=peak_power, + gamma=gamma, + fwhm=fwhm, + shape=shape, + nonlinear_phase=nonlinear_phase, + ) + raise ToolsError( + f"no phase-matched wavelength in {limits[0] * 1e9:.4g}-" + f"{limits[1] * 1e9:.4g} nm: the mismatch is {ends[1]:.4g} 1/m at the short " + f"end and {ends[0]:.4g} 1/m at the long end and never changes sign" + ) + return roots[-1] diff --git a/src/solprop/tools/pulse.py b/src/solprop/tools/pulse.py new file mode 100644 index 00000000..d513bb27 --- /dev/null +++ b/src/solprop/tools/pulse.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Pulse shape numbers: the natural width, peak power and energy of a pulse. + +The ``sech`` and Gaussian shapes the design numbers are defined for, and the carrier +frequency of a vacuum wavelength the other tools modules share. +""" + +import math +from typing import Literal, get_args + +from solprop._validation import choice, positive +from solprop.constants import C +from solprop.exceptions import ( + ToolsError, +) + +__all__ = ["PulseShape", "pulse_energy", "pulse_peak_power", "pulse_width"] + +PulseShape = Literal["sech", "gauss"] +"""The two pulse shapes the design numbers are defined for.""" + + +_SECH_WIDTH = 2.0 * math.log(1.0 + math.sqrt(2.0)) +"""``fwhm / tau0`` of ``sech^2(t / tau0)``: twice the inverse cosh of the root of 2.""" + + +_GAUSS_WIDTH = 2.0 * math.sqrt(math.log(2.0)) +"""``fwhm / tau0`` of ``exp(-t^2 / tau0^2)``.""" + + +def _omega(wavelength: float) -> float: + """Return the angular frequency of a vacuum wavelength. + + - ``wavelength``: vacuum wavelength in m, > 0. + - Returns: angular frequency in rad/s. + """ + return 2.0 * math.pi * C / positive(wavelength, "wavelength", ToolsError) + + +def pulse_width(fwhm: float, shape: PulseShape = "sech") -> float: + r"""Return the natural width of a pulse from its full width at half maximum. + + Parameters + ---------- + fwhm : float + Full width at half maximum of the **power** in s, > 0. + shape : {'sech', 'gauss'}, optional + ``"sech"`` for :math:`P\propto{\rm sech}^2(t/\tau_0)`, ``"gauss"`` for + :math:`P\propto\exp(-t^2/\tau_0^2)`. + + Returns + ------- + float + :math:`\tau_0` in s: ``fwhm / (2 ln(1 + sqrt 2))`` or ``fwhm / (2 sqrt(ln 2))``. + + Raises + ------ + ToolsError + If the shape is unknown or the width is not positive. + + Examples + -------- + >>> from solprop.tools import pulse_width + >>> f"{pulse_width(10e-15) * 1e15:.4f}" + '5.6730' + >>> f"{pulse_width(10e-15, 'gauss') * 1e15:.4f}" + '6.0056' + """ + choice(shape, "shape", get_args(PulseShape), ToolsError) + return positive(fwhm, "fwhm", ToolsError) / ( + _SECH_WIDTH if shape == "sech" else _GAUSS_WIDTH + ) + + +def pulse_peak_power(energy: float, fwhm: float, shape: PulseShape = "sech") -> float: + r"""Return the peak power of a pulse of given energy, duration and shape. + + Parameters + ---------- + energy : float + Pulse energy in J, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape; see :func:`pulse_width`. + + Returns + ------- + float + Peak power in W: :math:`E/(2\tau_0)` for ``sech`` and + :math:`E/(\sqrt\pi\,\tau_0)` for a Gaussian, the integrals of the two shapes. + + Raises + ------ + ToolsError + If the shape is unknown or a quantity is not positive. + + Examples + -------- + >>> from solprop.tools import pulse_peak_power + >>> f"{pulse_peak_power(1e-3, 10e-15) / 1e9:.3f}" + '88.137' + >>> f"{pulse_peak_power(1e-3, 10e-15, 'gauss') / 1e9:.3f}" + '93.944' + """ + width = pulse_width(fwhm, shape) + integral = 2.0 if shape == "sech" else math.sqrt(math.pi) + return positive(energy, "energy", ToolsError) / (integral * width) + + +def pulse_energy(peak_power: float, fwhm: float, shape: PulseShape = "sech") -> float: + """Return the energy of a pulse of given peak power, duration and shape. + + The inverse of :func:`pulse_peak_power`, for the same shape. + + Parameters + ---------- + peak_power : float + Peak power in W, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape; see :func:`pulse_width`. + + Returns + ------- + float + Pulse energy in J. + + Raises + ------ + ToolsError + If the shape is unknown or a quantity is not positive. + + Examples + -------- + >>> from solprop.tools import pulse_energy + >>> f"{pulse_energy(88.137e9, 10e-15) * 1e3:.4f}" + '1.0000' + """ + # The peak power is linear in the energy at a fixed width and shape. + return positive(peak_power, "peak_power", ToolsError) / pulse_peak_power( + 1.0, fwhm, shape + ) diff --git a/src/solprop/tools/soliton.py b/src/solprop/tools/soliton.py new file mode 100644 index 00000000..18e0b161 --- /dev/null +++ b/src/solprop/tools/soliton.py @@ -0,0 +1,452 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Soliton numbers: dispersion and nonlinear lengths, orders and constituents. + +The closed forms of a pulse in a mode of given dispersion and nonlinear coefficient, +the soliton content of an unchirped input, and the fill's nonlinear coefficient. +""" + +import math +from dataclasses import dataclass + +import numpy as np +from jaxtyping import Float +from scipy.integrate import solve_ivp +from scipy.optimize import brentq +from scipy.special import erfcinv + +from solprop._validation import positive +from solprop.constants import C +from solprop.exceptions import ( + ToolsError, +) +from solprop.modes import base +from solprop.modes.protocol import Mode +from solprop.tools.pulse import PulseShape, _omega, pulse_width + +__all__ = [ + "SolitonContent", + "dispersion_length", + "fission_length", + "nonlinear_coefficient", + "nonlinear_length", + "soliton_content", + "soliton_order", + "soliton_peak_power", +] + + +def dispersion_length(fwhm: float, beta2: float, shape: PulseShape = "sech") -> float: + r"""Return the dispersion length :math:`L_D = \tau_0^2/|\beta_2|`. + + Parameters + ---------- + fwhm : float + Full width at half maximum of the power in s, > 0. + beta2 : float + Group-velocity dispersion in s^2/m, non-zero. + shape : {'sech', 'gauss'}, optional + The pulse shape; see :func:`pulse_width`. + + Returns + ------- + float + Dispersion length in m. + + Raises + ------ + ToolsError + If the dispersion is zero or not finite, or the width is not positive. + + Examples + -------- + >>> from solprop.tools import dispersion_length + >>> f"{dispersion_length(10e-15, -8e-30):.3f}" + '4.023' + """ + if not math.isfinite(beta2) or beta2 == 0.0: + raise ToolsError(f"beta2 must be finite and non-zero, got {beta2!r}") + return pulse_width(fwhm, shape) ** 2 / abs(beta2) + + +def nonlinear_length(peak_power: float, gamma: float) -> float: + r"""Return the nonlinear length :math:`L_{NL} = 1/(\gamma P_0)`. + + Parameters + ---------- + peak_power : float + Peak power in W, > 0. + gamma : float + Nonlinear coefficient in rad/(W m), > 0. + + Returns + ------- + float + Nonlinear length in m. + + Raises + ------ + ToolsError + If either quantity is not positive. + + Examples + -------- + >>> from solprop.tools import nonlinear_length + >>> nonlinear_length(1e9, 1e-9) + 1.0 + """ + return 1.0 / ( + positive(gamma, "gamma", ToolsError) + * positive(peak_power, "peak_power", ToolsError) + ) + + +def soliton_order( + peak_power: float, + fwhm: float, + gamma: float, + beta2: float, + shape: PulseShape = "sech", +) -> float: + r"""Return the soliton order :math:`N = \sqrt{L_D/L_{NL}}`. + + The number is defined for either sign of the dispersion; it is a + soliton order in the physical sense only where ``beta2 < 0``. + + Parameters + ---------- + peak_power : float + Peak power in W, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + gamma : float + Nonlinear coefficient in rad/(W m), > 0. + beta2 : float + Group-velocity dispersion in s^2/m, non-zero. + shape : {'sech', 'gauss'}, optional + The pulse shape, used for the width **throughout**. + + Returns + ------- + float + The soliton order, dimensionless. + + Examples + -------- + >>> from solprop.tools import soliton_order + >>> f"{soliton_order(1e9, 10e-15, 1e-9, -8e-30):.4f}" + '2.0057' + """ + return math.sqrt( + dispersion_length(fwhm, beta2, shape) / nonlinear_length(peak_power, gamma) + ) + + +def soliton_peak_power( + order: float, + fwhm: float, + gamma: float, + beta2: float, + shape: PulseShape = "sech", +) -> float: + r"""Return the peak power that gives a soliton order. + + The inverse of :func:`soliton_order` at fixed dispersion, nonlinear coefficient + and width: :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|` is linear in the peak + power, so :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. :func:`pulse_energy` + turns it into an energy. On a mode, take `beta2` from + :func:`solprop.modes.base.beta2` at the pump and `gamma` from + :func:`nonlinear_coefficient`, both at the launch position. + + Parameters + ---------- + order : float + Conventional soliton order, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + gamma : float + Nonlinear coefficient in rad/(W m), > 0. + beta2 : float + Group-velocity dispersion in s^2/m, non-zero. + shape : {'sech', 'gauss'}, optional + The pulse shape, used for the width. + + Returns + ------- + float + Peak power in W. + + Raises + ------ + ToolsError + If a quantity is not positive, the dispersion is zero, or the shape is + unknown. + + Examples + -------- + >>> from solprop.tools import soliton_order, soliton_peak_power + >>> power = soliton_peak_power(2.0, 10e-15, 1e-9, -8e-30) + >>> f"{power / 1e9:.4f} {soliton_order(power, 10e-15, 1e-9, -8e-30):.4f}" + '0.9943 2.0000' + """ + return positive(order, "order", ToolsError) ** 2 / ( + positive(gamma, "gamma", ToolsError) * dispersion_length(fwhm, beta2, shape) + ) + + +@dataclass(frozen=True) +class SolitonContent: + """Describe the discrete soliton content of an unchirped single-lobe pulse. + + Attributes + ---------- + area_order : float + Dimensionless field area divided by pi, not the conventional soliton order. + count : int + Number of strictly positive discrete eigenvalues; zero at first threshold. + largest_eigenvalue : float or None + Imaginary part of the largest Zakharov-Shabat eigenvalue, dimensionless + with time scaled by the input shape's natural width. None if count is zero. + largest_peak_power : float or None + Peak power of the largest constituent in W, or None if count is zero. + largest_width : float or None + Natural sech width of that constituent in s, or None if count is zero. + largest_nonlinear_wavenumber : float or None + Positive optics-convention phase rate in 1/m, or None if count is zero. + """ + + area_order: float + count: int + largest_eigenvalue: float | None + largest_peak_power: float | None + largest_width: float | None + largest_nonlinear_wavenumber: float | None + + +def _scattering_angle( + x: float, theta: Float[np.ndarray, " n_components"], order: float, eta: float +) -> list[float]: + """Evaluate the real Zakharov-Shabat phase equation for a Gaussian.""" + return [-order * math.exp(-x * x / 2.0) - eta * math.sin(2 * theta[0])] + + +def _gaussian_eigenvalue(order: float) -> float: + """Solve the half-line scattering problem for the largest Gaussian eigenvalue. + + - ``order``: conventional soliton order, above the first Gaussian threshold. + """ + # The omitted field area is <= 1e-13. Moving the boundary with the amplitude + # avoids assuming that the Gaussian tail is negligible for every input N. + extent = math.sqrt(2.0) * float(erfcinv(1e-13 / (order * math.sqrt(math.pi / 2.0)))) + + def residual(eta: float) -> float: + """Return the ground-state symmetry residual at the pulse centre.""" + if eta == 0.0: + # The exact area protects the first threshold from tail truncation. + return math.pi / 4.0 - order * math.sqrt(math.pi / 2.0) + solution = solve_ivp( + _scattering_angle, + (-extent, 0.0), + [0.0], + method="DOP853", + args=(order, eta), + rtol=1e-11, + atol=1e-13, + ) + if not solution.success: + raise ToolsError( + f"Gaussian scattering integration failed: {solution.message}" + ) + return float(solution.y[0, -1]) + math.pi / 4.0 + + # The ground-state angle crosses -pi/4 once for 0 < eta < max(q). + # Unlike shooting the amplitudes, this does not overflow in the tails. + try: + return float(brentq(residual, 0.0, order, xtol=1e-13, rtol=1e-11)) + except (ValueError, RuntimeError) as error: + raise ToolsError(f"Gaussian eigenvalue refinement failed: {error}") from error + + +def soliton_content( + peak_power: float, + fwhm: float, + gamma: float, + beta2: float, + shape: PulseShape = "sech", +) -> SolitonContent: + r"""Calculate the soliton count and largest constituent of an unchirped pulse. + + This is the scattering content of the lossless cubic focusing NLSE, not a + prediction of fission dynamics with higher-order dispersion, Raman or plasma. + The conventional :func:`soliton_order` remains a separate design parameter. + + Parameters + ---------- + peak_power : float + Input peak power in W, finite and positive. + fwhm : float + Input power FWHM in s, finite and positive. + gamma : float + Nonlinear coefficient in rad/(W m), finite and positive. + beta2 : float + Anomalous group-velocity dispersion in s^2/m, finite and negative. + shape : {'sech', 'gauss'}, optional + Unchirped input field shape; the Gaussian power is exp(-t^2/tau0^2). + + Returns + ------- + SolitonContent + Analytic count and largest constituent. The sech eigenvalue is analytic; + the Gaussian eigenvalue is numerical, with dimensionless absolute accuracy + about 1e-10 away from thresholds. Relative accuracy degrades at threshold. + + Raises + ------ + ToolsError + If inputs are invalid, dispersion is not anomalous, or scattering fails. + + References + ---------- + .. [1] Klaus and Shaw, SIAM J. Math. Anal. 34, 759 (2003), + doi:10.1137/S0036141002403067. + .. [2] Kim, Lee and Lyng, arXiv:1310.4145, sections 1-2. + """ + if not math.isfinite(beta2) or beta2 >= 0.0: + raise ToolsError( + "soliton content requires finite anomalous dispersion (beta2 < 0)" + ) + order = soliton_order(peak_power, fwhm, gamma, beta2, shape) + area_order = order if shape == "sech" else math.sqrt(2.0 / math.pi) * order + if not math.isfinite(area_order): + raise ToolsError("the normalised pulse area must be finite") + # Strictly positive eigenvalues only: at a half-integer area the new state is + # at zero and is not a square-integrable soliton (Klaus-Shaw threshold). + count = max(0, math.ceil(area_order - 0.5)) + if count == 0: + return SolitonContent(area_order, 0, None, None, None, None) + eta = order - 0.5 if shape == "sech" else _gaussian_eigenvalue(order) + if eta <= 0.0: + raise ToolsError( + "the positive Gaussian eigenvalue is too close to threshold to resolve" + ) + power = peak_power * (2.0 * eta / order) ** 2 + width = pulse_width(fwhm, shape) / (2.0 * eta) + return SolitonContent(area_order, count, eta, power, width, 0.5 * gamma * power) + + +def fission_length( + peak_power: float, + fwhm: float, + gamma: float, + beta2: float, + shape: PulseShape = "sech", +) -> float: + r"""Return the soliton fission length :math:`L_{\rm fiss} = L_D/N`. + + Parameters + ---------- + peak_power : float + Peak power in W, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + gamma : float + Nonlinear coefficient in rad/(W m), > 0. + beta2 : float + Group-velocity dispersion in s^2/m, non-zero. + shape : {'sech', 'gauss'}, optional + The pulse shape; see :func:`pulse_width`. + + Returns + ------- + float + Fission length in m, the geometric mean of the two lengths. + + Examples + -------- + >>> from solprop.tools import fission_length + >>> f"{fission_length(1e9, 10e-15, 1e-9, -8e-30):.4f}" + '2.0057' + """ + order = soliton_order(peak_power, fwhm, gamma, beta2, shape) + return dispersion_length(fwhm, beta2, shape) / order + + +def nonlinear_coefficient( + mode: Mode, n2: float, wavelength: float, z: float = 0.0 +) -> float: + r"""Return the nonlinear coefficient :math:`\gamma = n_2\omega/(cA_{\rm eff})`. + + Parameters + ---------- + mode : Mode + The mode, for its effective area. + n2 : float + Nonlinear index of the fill in m^2/W, >= 0. + wavelength : float + Vacuum wavelength in m, > 0. + z : float, optional + Position along the waveguide, m. + + Returns + ------- + float + Nonlinear coefficient in rad/(W m). + + Raises + ------ + ToolsError + If the nonlinear index is negative or not finite. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import nonlinear_coefficient + >>> mode = MarcatiliMode(125e-6, "He", 1.0) + >>> f"{nonlinear_coefficient(mode, 3.55e-25, 800e-9):.4e}" + '1.1919e-10' + """ + if not math.isfinite(n2) or n2 < 0.0: + raise ToolsError(f"n2 must be finite and not negative, got {n2!r}") + return n2 * _omega(wavelength) / (C * base.aeff(mode, z)) + + +def _area_per_order(shape: PulseShape) -> float: + """Return the pulse area over pi per unit conventional soliton order. + + - ``shape``: the pulse shape. + """ + return 1.0 if shape == "sech" else math.sqrt(2.0 / math.pi) + + +def _gaussian_order(eigenvalue: float) -> float: + """Return the conventional order whose Gaussian has a given largest eigenvalue. + + - ``eigenvalue``: target imaginary part of the largest Zakharov-Shabat eigenvalue, > + 0. + """ + threshold = 0.5 / _area_per_order("gauss") + + def residual(order: float) -> float: + """Return the largest eigenvalue at an order less the target.""" + # At and below the first threshold there is no bound state: the eigenvalue + # that appears there starts from zero. + if order <= threshold: + return -eigenvalue + return _gaussian_eigenvalue(order) - eigenvalue + + # N - eta of the Gaussian falls from sqrt(pi/8) = 0.627 at the first threshold + # towards 1/2 as N grows (0.579 at N = 1, 0.506 at N = 10, measured with + # _gaussian_eigenvalue), so the root lies between eta + 1/2 and eta + 0.627. The + # upper end carries a margin; brentq refuses a bracket that does not hold the root. + low = max(threshold, eigenvalue + 0.5) + high = eigenvalue + 0.7 + try: + return float(brentq(residual, low, high, xtol=1e-12, rtol=1e-11)) + except ValueError as error: + raise ToolsError( + f"no Gaussian soliton order between {low:.6g} and {high:.6g} has a largest " + f"eigenvalue of {eigenvalue:.6g}: {error}" + ) from error diff --git a/src/solprop/tools/waveguide.py b/src/solprop/tools/waveguide.py new file mode 100644 index 00000000..e5eec2df --- /dev/null +++ b/src/solprop/tools/waveguide.py @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""The fill and the mode a design evaluates. + +A fill as the run interfaces accept it, resolved to an optmat mixture; its Kerr +coefficient; the default cladding; and the zero-dispersion point of a mode nearest a +wavelength. Every design search evaluates its probes inside :func:`_probing`. +""" + +import math +import warnings +from collections.abc import Iterator, Mapping +from contextlib import contextmanager + +import numpy as np + +from solprop.constants import ROOM_TEMPERATURE, C +from solprop.exceptions import ( + DiluteScalingWarning, + MaterialError, + MaterialRangeWarning, + ToolsError, +) +from solprop.modes import base +from solprop.modes.protocol import Mode +from solprop.physdata import ( + Gas, + Mixture, + n2_from_chi3, + species, +) +from solprop.physdata.glasses import GlassIndex, fused_silica +from solprop.physdata.nonlinear_data import carriers_agree +from solprop.physdata.selection import MaterialSelection +from solprop.profiles import ZProfile +from solprop.tools.pulse import _omega + +__all__ = ["Fill"] + +Fill = Gas | str | Mapping[str, float] | Mixture +"""A fill as the run interfaces accept it: a species, mole fractions or a mixture.""" + + +_ZDW_RANGE = (100e-9, 3e-6) +"""Window of the zero-dispersion search in m: :func:`modes.base.zdw`'s.""" + + +_ZDW_SAMPLES = 512 +"""Samples, evenly spaced in frequency, on which the dispersion's sign is read.""" + + +_ZDW_REFINE_ROUNDS = 4 +"""Rounds of array samples that refine a sign change of the dispersion. Four take a +bracket of 3.6e13 rad/s to 3.4e7 rad/s, where interpolating linearly reaches the noise +of the differenced ``beta2``.""" + + +_ZDW_REFINE_SAMPLES = 33 +"""Samples of each refinement round: 32 intervals, so each round narrows 32-fold.""" + + +_POLE_FACTOR = 2.0 +"""How far the group delay at a refined sign change of ``beta2`` may sit from its value +at the bracket's ends, in units of the change a bounded ``beta2`` could produce across +the bracket, before the sign change is read as a pole rather than a zero. At a zero +``beta1`` is extremal and moves by at most half that unit; at a gas resonance it +diverges. The differenced ``beta2`` itself cannot tell them apart: ``beta1`` is even +about a pole, so its central difference vanishes there too.""" + + +@contextmanager +def _probing() -> Iterator[None]: + """Silence material-range and dilute-scaling warnings while a search samples. + + A root search evaluates the index across a window far wider than its answer -- + in frequency, or in pressure up to a hundred bar, where an optmat fill's linear + density scaling departs from its finite-density law -- and the ends are probes, + not results. The answer itself is re-evaluated outside this context, so a root + that lies outside a model's range, or at a density beyond the dilute law, + still warns. + """ + with warnings.catch_warnings(): + warnings.simplefilter("ignore", MaterialRangeWarning) + warnings.simplefilter("ignore", DiluteScalingWarning) + yield + + +def _resolve_fill( + gas: Fill, + pressure: float | None, + temperature: float | None, + materials: MaterialSelection | None = None, +) -> Mixture: + """Return the mixture a fill declaration means, as the run interfaces read it. + + - ``pressure``: total pressure in bar; must be ``None`` beside a `Mixture`, which + carries its own, and defaults to one bar otherwise. + - ``temperature``: fill temperature in K; ``None`` is room temperature, or the + mixture's own beside a `Mixture`, which refuses any other. + - ``materials``: resolve a name, a fraction mapping's names and ``"air"`` from + optmat under this selection, as the drivers' ``capillary_fill`` does; ``None`` is + ``MaterialSelection()``. A mixture is taken as it is. + """ + if isinstance(gas, Mixture): + if pressure is not None: + raise ToolsError("a Mixture carries its own pressure; omit pressure") + if isinstance(gas.temperature, ZProfile): + raise ToolsError( + "the design calculators take one temperature in K, and this mixture's " + "varies along z; give a Mixture at one temperature" + ) + if temperature is not None and float(temperature) != gas.temperature: + # As the run interfaces: the mixture's densities were set at its own + # temperature, so another one cannot be honoured. + raise ToolsError( + f"the Mixture carries its own temperature ({gas.temperature:g} K), " + f"and temperature={temperature!r} was given beside it: omit " + "temperature=, or build the Mixture at the temperature you mean" + ) + return gas + temperature = ROOM_TEMPERATURE if temperature is None else temperature + total = 1.0 if pressure is None else float(pressure) + if isinstance(gas, Mapping): + fractions = { + species(name, selection=materials): fraction + for name, fraction in gas.items() + } + return Mixture.fill(fractions, total, temperature=temperature) + if gas is Gas.Air or (isinstance(gas, str) and gas.strip().lower() == "air"): + return Mixture.air( + total, temperature=temperature, argon=False, selection=materials + ) + return Mixture.fill( + {species(gas, selection=materials): 1.0}, total, temperature=temperature + ) + + +def _check_carrier(materials: MaterialSelection | None, wavelength: float) -> None: + """Refuse a selection whose carrier is not the tool's pump wavelength. + + A run under a selection refuses a carrier other than its grid's reference + wavelength, so a design tool refuses one other than the wavelength it evaluates + the Kerr coefficient at: the two then evaluate it at the same carrier. + + - ``wavelength``: the pump wavelength in m. + """ + carrier = None if materials is None else materials.carrier_wavelength + if carrier is not None and not carriers_agree(float(carrier), wavelength): + msg = ( + f"the selection's carrier_wavelength is {carrier:g} m and the design's " + f"wavelength {wavelength:g} m: one carrier sets the Kerr coefficient of " + "every optmat species, as in a run. Leave the selection's " + "carrier_wavelength unset, or design at it" + ) + raise ToolsError(msg) + + +def _default_cladding( + cladding: GlassIndex | complex | None, +) -> GlassIndex | complex: + """Return the cladding a design's capillary takes, as the capillary driver does. + + - ``cladding``: the cladding given; ``None`` is the package default. + """ + return fused_silica() if cladding is None else cladding + + +def _fill_chi3(mixture: Mixture, wavelength: float, z: float = 0.0) -> float: + """Return a fill's density-weighted third-order susceptibility. + + - ``wavelength``: vacuum wavelength in m, the carrier at which an optmat Kerr model + is taken. + - ``z``: position in m at which the densities are evaluated. + - Returns: ``chi3`` in m^2/V^2. + """ + try: + return float(mixture.gamma3_path(wavelength)(np.array([float(z)]))[0]) + except MaterialError as error: + raise ToolsError(f"the fill has no nonlinear index: {error}") from error + + +def _fill_n2( + mixture: Mixture, wavelength: float, z: float = 0.0 +) -> tuple[float, float]: + """Return a fill's nonlinear index and its linear index at a wavelength. + + - ``wavelength``: vacuum wavelength in m, the carrier at which an optmat Kerr model + is taken. + - ``z``: position in m at which the densities are evaluated. + - Returns: ``n2`` in m^2/W, from the density-weighted ``chi3`` and the exact + ``n0^2`` of the fill, and that linear index ``n0``. + """ + chi3 = _fill_chi3(mixture, wavelength, z) + here = np.array([float(z)]) + susceptibility = mixture.susceptibility(np.array([_omega(wavelength)]), here)[0, 0] + index = float(np.real(np.sqrt(1.0 + susceptibility))) + return n2_from_chi3(chi3, index), index + + +def _refine_zero(mode: Mode, low: float, high: float, z: float) -> float: + """Return a sign change of the dispersion inside a bracket, by array samples. + + A mode's index models evaluate an array at about the cost of one point, so each + round narrows the bracket 32-fold for the cost of one Brent step, where a scalar + search costs a glass model evaluation per iterate. The rounds stop short of a pole + of the differenced ``beta2``, which a scalar search converges onto until the model + returns NaN. + + - ``low``, ``high``: the bracket, rad/s, whose ends have ``beta2`` of opposite + signs. + - ``z``: position along the waveguide, m. + - Returns: the first sign change upwards in frequency, rad/s, linearly interpolated + in the last bracket; or NaN when samples that are not finite separate the signs. + """ + first = last = math.nan + for _ in range(_ZDW_REFINE_ROUNDS): + grid = np.linspace(low, high, _ZDW_REFINE_SAMPLES) + sampled = base.beta2(mode, grid, z) + crossing = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) <= 0.0) + if crossing.size == 0: + return math.nan + i = int(crossing[0]) + low, high = float(grid[i]), float(grid[i + 1]) + # The first change has a nonzero sample below it, so the two cannot be equal. + first, last = float(sampled[i]), float(sampled[i + 1]) + return low - first * (high - low) / (last - first) + + +def _zdw_near(mode: Mode, omega: float, z: float) -> float: + """Return the zero-dispersion wavelength nearest a pump frequency. + + :func:`solprop.modes.base.zdw` brackets one sign change across its whole window, + which a gas resonance in the vacuum ultraviolet defeats: krypton flips the sign of + the dispersion twice near 120 nm, the ends then agree, and the zero at 769 nm of a + 14 um capillary at 15 bar is refused. Here the sign is sampled, the changes are + refined outwards from the pump, the changes through a pole are dropped, and the zero + nearest the pump is the one reported; a change further than the best so far cannot + beat it, which spares the dozens of changes an antiresonant fibre's resonances + give. Two limits of a sampled search remain: a zero and a pole inside one sample + interval (3.6e13 rad/s) restore the sign and are both missed, and a sample that is + not finite cannot bracket anything. + + - ``omega``: pump angular frequency in rad/s. + - ``z``: position along the waveguide, m. + - Returns: wavelength in m, or NaN when the dispersion has no zero in 100 nm to 3 + um. + """ + grid = np.linspace( + 2.0 * math.pi * C / _ZDW_RANGE[1], + 2.0 * math.pi * C / _ZDW_RANGE[0], + _ZDW_SAMPLES, + ) + nearest = math.nan + # The refinement converges onto a pole as readily as onto a zero, and a model may + # divide by zero exactly there; the group-delay test below tells the two apart. + with _probing(), np.errstate(divide="ignore", invalid="ignore"): + sampled = base.beta2(mode, grid, z) + changes = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) < 0.0) + # How near each bracket can bring a zero: 0 for one holding the pump. + reach = np.maximum.reduce( + [grid[changes] - omega, omega - grid[changes + 1], np.zeros(changes.size)] + ) + for i in changes[np.argsort(reach, kind="stable")]: + low, high = float(grid[i]), float(grid[i + 1]) + if not math.isnan(nearest) and max(low - omega, omega - high) >= abs( + nearest - omega + ): + break + root = _refine_zero(mode, low, high, z) + if math.isnan(root): + continue + delay = base.beta1(mode, np.array([low, root, high]), z) + bounded = max(abs(sampled[i]), abs(sampled[i + 1])) * (high - low) + closer = math.isnan(nearest) or abs(root - omega) < abs(nearest - omega) + if closer and abs(delay[1] - delay[0]) <= _POLE_FACTOR * bounded: + nearest = root + if math.isnan(nearest): + return math.nan + # Outside the probing context, so a zero beyond the index model's range warns. + base.beta(mode, np.array([nearest]), z) + return 2.0 * math.pi * C / nearest diff --git a/tests/recipe/test_pins.py b/tests/recipe/test_pins.py index 497b4897..ed90b8e0 100644 --- a/tests/recipe/test_pins.py +++ b/tests/recipe/test_pins.py @@ -24,7 +24,7 @@ pulse_peak_power, soliton_order, ) -from solprop.tools import _fill_n2 as fill_n2 +from solprop.tools.waveguide import _fill_n2 as fill_n2 ZDW_PIN = { "target": "zdw", diff --git a/tests/recipe/test_preview.py b/tests/recipe/test_preview.py index 42c1a745..ba9504c2 100644 --- a/tests/recipe/test_preview.py +++ b/tests/recipe/test_preview.py @@ -14,12 +14,12 @@ from solprop.recipe import Recipe, RecipeError from solprop.recipe.preview import _phase_matching, _pump from solprop.tools import ( - _fill_n2, lambda_rdw_all, nonlinear_coefficient, phase_mismatch, pulse_peak_power, ) +from solprop.tools.waveguide import _fill_n2 @pytest.fixture(autouse=True) @@ -76,7 +76,7 @@ def test_a_mode_without_a_fill_or_kerr_data_has_no_gamma(monkeypatch): def refuse(*_args): raise ToolsError("no Kerr data") - monkeypatch.setattr("solprop.tools._fill_n2", refuse) + monkeypatch.setattr("solprop.tools.waveguide._fill_n2", refuse) linear = Recipe.example("rdw").modes() assert linear.modes[0].gamma is None # Without the fill's Kerr data the phase matching is the linear condition. diff --git a/tests/test_design_solvers.py b/tests/test_design_solvers.py index 31f92e81..950e381b 100644 --- a/tests/test_design_solvers.py +++ b/tests/test_design_solvers.py @@ -26,6 +26,9 @@ radius_zdw, solve_parameter, ) +from solprop.tools import design as tools_design +from solprop.tools import pulse as tools_pulse +from solprop.tools import waveguide as tools_waveguide PUMP = 800e-9 OMEGA0 = 2.0 * math.pi * C / PUMP @@ -36,7 +39,7 @@ def capillary(gas, radius=125e-6): """The capillary pressure_zdw and pressure_rdw re-evaluate at each pressure.""" - return tools._design_capillary( + return tools_design._design_capillary( radius, gas, temperature=293.15, @@ -53,9 +56,9 @@ def pressure_builder(gas): fill = capillary(gas) def build(pressure): - mode = tools._capillary_at(fill, pressure) + mode = tools_design._capillary_at(fill, pressure) assert mode.mixture is not None # _design_capillary builds on a mixture. - n2 = tools._fill_n2(mode.mixture, PUMP)[0] + n2 = tools_waveguide._fill_n2(mode.mixture, PUMP)[0] return mode, tools.nonlinear_coefficient(mode, n2, PUMP) return build @@ -94,7 +97,9 @@ def test_the_zdw_target_reproduces_pressure_zdw_where_it_has_one_root(): expected = tools.pressure_zdw(125e-6, "Ar", 500e-9, cladding=GLASS) fill = capillary("Ar") roots = solve_parameter( - lambda pressure: tools._capillary_at(fill, pressure), (1e-6, 100.0), ZDW(500e-9) + lambda pressure: tools_design._capillary_at(fill, pressure), + (1e-6, 100.0), + ZDW(500e-9), ) assert len(roots) == 1 # pressure_zdw brackets once and stops at xtol = 2e-12 bar, this search refines @@ -169,7 +174,9 @@ def test_the_pulse_for_an_order_is_closed_form(): [ (sech(peak_power=1e9), "sech", 1e9), ( - SechPulse(wavelength=PUMP, width=10e-15 / tools._SECH_WIDTH, energy=1e-5), + SechPulse( + wavelength=PUMP, width=10e-15 / tools_pulse._SECH_WIDTH, energy=1e-5 + ), "sech", tools.pulse_peak_power(1e-5, 10e-15, "sech"), ), @@ -258,7 +265,7 @@ def test_an_index_model_short_of_the_window_leaves_the_range_undefined(monkeypat def short(*arguments, **keywords): raise ToolsError("the phase mismatch is not finite") - monkeypatch.setattr(tools, "lambda_rdw_all", short) + monkeypatch.setattr(tools_design, "lambda_rdw_all", short) roots = solve_parameter(pressure_builder("He"), (1.0, 2.0), RDW(PUMP, 120e-9)) assert roots.attainable is None @@ -267,7 +274,9 @@ def short(*arguments, **keywords): def test_an_unreachable_zero_reports_the_zeros_it_reaches(): fill = capillary("He") roots = solve_parameter( - lambda pressure: tools._capillary_at(fill, pressure), (0.1, 2.0), ZDW(250e-9) + lambda pressure: tools_design._capillary_at(fill, pressure), + (0.1, 2.0), + ZDW(250e-9), ) assert roots.attainable is not None # The helium capillary's zero moves from the blue towards 560 nm as the pressure @@ -301,7 +310,7 @@ def test_a_resonance_gives_both_of_its_roots(): @pytest.mark.medium def test_a_wall_resonance_gives_a_zero_on_each_side(): radius = 15e-6 - fill = tools._resolve_fill("He", 1.0, 293.15, MaterialSelection()) + fill = tools_waveguide._resolve_fill("He", 1.0, 293.15, MaterialSelection()) def build(wall): # Two terms of the fits keep the first resonance and cost a quarter of eight. @@ -320,7 +329,7 @@ def build(wall): assert roots.pick("lowest") < resonance < roots.pick("highest") for wall in (roots.pick("lowest"), roots.pick("highest")): # Brent stops at 2e-12 of the wall; beta2 is good to 5e-9 (modes.base). - assert tools._zdw_near(build(wall), OMEGA0, 0.0) == pytest.approx( + assert tools_waveguide._zdw_near(build(wall), OMEGA0, 0.0) == pytest.approx( PUMP, rel=1e-6 ) # Between consecutive roots the sign alternates: none was dropped between two. diff --git a/tests/test_soliton_content.py b/tests/test_soliton_content.py index a8b8e3d2..17d37db7 100644 --- a/tests/test_soliton_content.py +++ b/tests/test_soliton_content.py @@ -24,6 +24,8 @@ from solprop.nonlinear.gnlse import SpecifiedMode from solprop.physdata import n2 from solprop.physdata.selection import MaterialSelection +from solprop.tools import design as tools_design +from solprop.tools import soliton as tools_soliton PUMP = 800e-9 OMEGA = 2 * math.pi * C / PUMP @@ -282,13 +284,15 @@ def test_pressure_search_inverts_constituent_phase_for_both_modes(shape, vincett def test_pressure_search_rejects_ambiguous_or_inadmissible_roots(): with pytest.raises(ToolsError, match=r"2 roots, at 2, 3 bar"): - tools._rdw_pressure_root(lambda p: (p - 2) * (p - 3), (1, 4)) + tools_design._rdw_pressure_root(lambda p: (p - 2) * (p - 3), (1, 4)) with pytest.raises(ToolsError, match="no pressure"): - tools._rdw_pressure_root(lambda p: None if 2 < p < 3 else p - 2.5, (1, 4)) + tools_design._rdw_pressure_root( + lambda p: None if 2 < p < 3 else p - 2.5, (1, 4) + ) with pytest.raises(ToolsError, match="not finite"): - tools._rdw_pressure_root(lambda p: math.nan, (1, 4)) + tools_design._rdw_pressure_root(lambda p: math.nan, (1, 4)) with pytest.raises(ToolsError, match="ordered"): - tools._rdw_pressure_root(lambda p: p, (4, 1)) + tools_design._rdw_pressure_root(lambda p: p, (4, 1)) def test_params_report_constituents_and_normal_dispersion_unavailability(): @@ -304,14 +308,14 @@ def test_params_report_constituents_and_normal_dispersion_unavailability(): def test_wavelength_search_computes_gaussian_scattering_only_once(monkeypatch): - original = tools._gaussian_eigenvalue + original = tools_soliton._gaussian_eigenvalue calls = [] def counted(order): calls.append(order) return original(order) - monkeypatch.setattr(tools, "_gaussian_eigenvalue", counted) + monkeypatch.setattr(tools_soliton, "_gaussian_eigenvalue", counted) mode = SpecifiedMode.from_taylor([0, 0, -1e-26, 5e-41], OMEGA) tools.lambda_rdw( mode, PUMP, peak_power=2e4, gamma=1e-3, fwhm=100e-15, shape="gauss" @@ -343,7 +347,7 @@ def test_pressure_constituent_search_rejects_subthreshold_pulses(): def test_failed_scattering_integration_is_reported(monkeypatch): monkeypatch.setattr( - tools, + tools_soliton, "solve_ivp", lambda *args, **kwargs: SimpleNamespace( success=False, message="test integration failure" @@ -357,7 +361,7 @@ def test_failed_eigenvalue_bracket_is_reported(monkeypatch): def failed(*args, **kwargs): raise ValueError("test bracket failure") - monkeypatch.setattr(tools, "brentq", failed) + monkeypatch.setattr(tools_soliton, "brentq", failed) with pytest.raises(ToolsError, match="eigenvalue refinement failed"): content(1, "gauss") @@ -376,7 +380,7 @@ def test_unresolved_positive_eigenvalue_is_rejected(): def test_pressure_refinement_refuses_an_unresolved_no_soliton_gap(): root = 2.123456 with pytest.raises(ToolsError, match="crossed a no-soliton region"): - tools._rdw_pressure_root( + tools_design._rdw_pressure_root( lambda p: None if abs(p - root) < 1e-5 else p - root, (1.0, 4.0) ) @@ -385,7 +389,7 @@ def test_pressure_search_requires_a_nonempty_bracket(): with pytest.raises(ToolsError, match="pressure_max must exceed"): tools.pressure_rdw(125e-6, "He", 200e-9, PUMP, pressure_max=1e-7) with pytest.raises(ToolsError, match="no pressure between"): - tools._pressure_root(lambda p: 1.0, 100.0, "an unreachable target") + tools_design._pressure_root(lambda p: 1.0, 100.0, "an unreachable target") @pytest.mark.parametrize( @@ -395,7 +399,9 @@ def test_pressure_search_requires_a_nonempty_bracket(): def test_pressure_search_rejects_invalid_pump_dispersion( monkeypatch, dispersion, message ): - monkeypatch.setattr(tools.base, "beta2", lambda *args: np.array([dispersion])) + monkeypatch.setattr( + tools_design.base, "beta2", lambda *args: np.array([dispersion]) + ) with pytest.raises(ToolsError, match=message): tools.pressure_rdw( 125e-6, "He", 200e-9, PUMP, peak_power=1e9, fwhm=30e-15, cladding=1.45 diff --git a/tests/test_tools.py b/tests/test_tools.py index 3c295acd..a4a1548a 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -48,6 +48,9 @@ from solprop.spatial import critical_power from solprop.stats import StatsSpec from solprop.stepper import FixedSchedule +from solprop.tools import design as tools_design +from solprop.tools import soliton as tools_soliton +from solprop.tools import waveguide as tools_waveguide PUMP = 800e-9 OMEGA0 = 2.0 * math.pi * C / PUMP @@ -742,13 +745,15 @@ def test_a_largest_eigenvalue_is_met(shape, eigenvalue): def test_the_gaussian_inverse_returns_the_documented_unit_order(): # docs/guide/workflows/design-tools.md: a Gaussian with N = 1 has eta = 0.4207333. - assert tools._gaussian_order(0.4207333062232459) == pytest.approx(1.0, abs=1e-9) + assert tools_soliton._gaussian_order(0.4207333062232459) == pytest.approx( + 1.0, abs=1e-9 + ) def test_a_gaussian_inverse_without_a_root_in_its_bracket_is_refused(monkeypatch): - monkeypatch.setattr(tools, "_gaussian_eigenvalue", lambda order: 0.0) + monkeypatch.setattr(tools_soliton, "_gaussian_eigenvalue", lambda order: 0.0) with pytest.raises(ToolsError, match="no Gaussian soliton order"): - tools._gaussian_order(1.0) + tools_soliton._gaussian_order(1.0) def test_a_conventional_order_in_normal_dispersion_is_designed(): @@ -826,7 +831,7 @@ def pole_mode(pole, strength=1e-12): def test_a_sign_change_through_a_pole_is_not_a_zero_dispersion_point(): # Placed off the sample grid, so that no sample lands on the singularity itself. pole = 2.0 * math.pi * C / 431.7e-9 - assert math.isnan(tools._zdw_near(pole_mode(pole), OMEGA0, 0.0)) + assert math.isnan(tools_waveguide._zdw_near(pole_mode(pole), OMEGA0, 0.0)) def test_a_sign_change_across_samples_that_are_not_finite_is_not_a_zero(): @@ -834,9 +839,9 @@ def test_a_sign_change_across_samples_that_are_not_finite_is_not_a_zero(): # samples of the first search: they bracket the only sign change, and every round # of the refinement finds samples that are not finite between the two signs. grid = np.linspace( - 2.0 * math.pi * C / tools._ZDW_RANGE[1], - 2.0 * math.pi * C / tools._ZDW_RANGE[0], - tools._ZDW_SAMPLES, + 2.0 * math.pi * C / tools_waveguide._ZDW_RANGE[1], + 2.0 * math.pi * C / tools_waveguide._ZDW_RANGE[0], + tools_waveguide._ZDW_SAMPLES, ) centre = 0.5 * (grid[20] + grid[21]) @@ -852,7 +857,7 @@ def delay(w): effective_area=1.0, ) assert np.all(np.isfinite(base.beta2(mode, grid))) - assert math.isnan(tools._zdw_near(mode, OMEGA0, 0.0)) + assert math.isnan(tools_waveguide._zdw_near(mode, OMEGA0, 0.0)) @pytest.mark.parametrize( @@ -880,7 +885,7 @@ def test_the_array_refinement_of_a_zero_agrees_with_a_brent_search( 2.0 * math.pi * C / 3e-6, 2.0 * math.pi * C / 200e-9, ) - wavelength = tools._zdw_near(mode, OMEGA0, 0.0) + wavelength = tools_waveguide._zdw_near(mode, OMEGA0, 0.0) # beta2 is a difference, good to about 5e-9 (modes.base); both stop on that noise. assert wavelength == pytest.approx(2.0 * math.pi * C / reference, rel=1e-8) @@ -991,7 +996,7 @@ def test_an_antiresonant_fibre_s_resonances_are_skipped_not_raised(): def test_a_residual_that_is_not_a_number_is_a_tools_error(): with pytest.raises(ToolsError, match="not finite at the ends"): - tools._pressure_root(lambda pressure: math.nan, 10.0, "anything") + tools_design._pressure_root(lambda pressure: math.nan, 10.0, "anything") def test_the_tools_build_the_capillary_the_run_interface_builds(): @@ -1196,7 +1201,7 @@ def test_air_and_a_tube_lattice_design_under_a_selection(): assert air == quiet(tools.pressure_zdw, 125e-6, "Air", 600e-9) assert 0 < air < math.inf # A tube lattice is built as prop_capillary builds it under the selection. - tubes = tools._design_capillary( + tubes = tools_design._design_capillary( 15e-6, "Ar", temperature=294.0, @@ -1217,7 +1222,7 @@ def test_the_nearest_zero_is_found_without_refining_every_resonance(monkeypatch) mode = Recipe.example("antiresonant_fibre").build().modes[0] pump = 2.0 * math.pi * C / 800e-9 - refine = tools._refine_zero + refine = tools_waveguide._refine_zero calls = [] def counted(*args): @@ -1225,16 +1230,16 @@ def counted(*args): calls.append(root) return root - monkeypatch.setattr(tools, "_refine_zero", counted) - found = tools._zdw_near(mode, pump, 0.0) + monkeypatch.setattr(tools_waveguide, "_refine_zero", counted) + found = tools_waveguide._zdw_near(mode, pump, 0.0) assert len(calls) <= 4 # What refining every change would have reported. grid = np.linspace( - 2.0 * math.pi * C / tools._ZDW_RANGE[1], - 2.0 * math.pi * C / tools._ZDW_RANGE[0], - tools._ZDW_SAMPLES, + 2.0 * math.pi * C / tools_waveguide._ZDW_RANGE[1], + 2.0 * math.pi * C / tools_waveguide._ZDW_RANGE[0], + tools_waveguide._ZDW_SAMPLES, ) - with tools._probing(), np.errstate(divide="ignore", invalid="ignore"): + with tools_waveguide._probing(), np.errstate(divide="ignore", invalid="ignore"): sampled = base.beta2(mode, grid, 0.0) changes = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) < 0.0) assert changes.size > 10 From 90f5546a4baa3e01ea8b6ed3e214ae97650ff2a2 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:55:32 +0000 Subject: [PATCH 06/42] Point the tools cross-references at their home modules Co-Authored-By: Claude Opus 5.5 --- docs/guide/free-space/beams.md | 2 +- docs/guide/front-end/classes.md | 2 +- docs/guide/front-end/first-run.md | 2 +- docs/guide/media/material-selection.md | 2 +- docs/guide/setup/recipes.md | 6 +-- docs/guide/workflows/design-tools.md | 68 +++++++++++++------------- docs/guide/workflows/interactive.md | 2 +- src/solprop/modes/guide.py | 4 +- src/solprop/recipe/pins.py | 6 +-- src/solprop/recipe/preview.py | 10 ++-- src/solprop/tools/__init__.py | 17 ++++--- src/solprop/tools/capillary.py | 28 ++++++----- src/solprop/tools/design.py | 50 ++++++++++--------- src/solprop/tools/phase_matching.py | 15 +++--- src/solprop/tools/soliton.py | 14 +++--- src/solprop/tools/waveguide.py | 2 +- 16 files changed, 119 insertions(+), 111 deletions(-) diff --git a/docs/guide/free-space/beams.md b/docs/guide/free-space/beams.md index 2ba42d52..45964f86 100644 --- a/docs/guide/free-space/beams.md +++ b/docs/guide/free-space/beams.md @@ -342,7 +342,7 @@ plane, {py:func}`~solprop.processing.beams.radial_spectrum` and {py:func}`~solprop.processing.beams.far_field` its energy per solid angle against wavelength, where conical emission shows as rings. The angle is the one outside the medium. The [filament reader example](../../auto_tutorials/free_space/plot_11_radial_filament.rst) uses them, -and {py:func}`~solprop.tools.beam_params` gives a beam's Rayleigh range, critical power and +and {py:func}`~solprop.tools.beam.beam_params` gives a beam's Rayleigh range, critical power and Marburger distance. ## Where it runs diff --git a/docs/guide/front-end/classes.md b/docs/guide/front-end/classes.md index 89a34d80..9bd5df7f 100644 --- a/docs/guide/front-end/classes.md +++ b/docs/guide/front-end/classes.md @@ -14,7 +14,7 @@ here. The pages themselves are described in [Reading the results](analysis.md). **Capillary** is a gas-filled hollow [capillary](../waveguides/capillaries.md), mode-averaged or with several modes (the *Modes* row; a multimode run adds the *Modes* page). Its numbers come from -{py:func}`solprop.tools.capillary_params`: the soliton order, the zero-dispersion +{py:func}`solprop.tools.capillary.capillary_params`: the soliton order, the zero-dispersion wavelength and the dispersive wave, each of which can be set as a target, and the peak power and intensity, the fission length and the loss at the pump. Its preview is the group-velocity dispersion at the entrance. Its pages are *Overview*, *At this plane*, diff --git a/docs/guide/front-end/first-run.md b/docs/guide/front-end/first-run.md index 76d65684..7a67ba7f 100644 --- a/docs/guide/front-end/first-run.md +++ b/docs/guide/front-end/first-run.md @@ -27,7 +27,7 @@ with a *Find any setting* box. As you type, the right side follows: - **What this gives** is computed by solprop from the setup as it stands - ({py:func}`solprop.tools.capillary_params` for a capillary): the soliton order, the + ({py:func}`solprop.tools.capillary.capillary_params` for a capillary): the soliton order, the zero-dispersion wavelength, the wavelength of the dispersive wave, the peak power and intensity, the fission length and the loss at the pump. - The plot is the **group-velocity dispersion at the entrance**, with the pump, the diff --git a/docs/guide/media/material-selection.md b/docs/guide/media/material-selection.md index 64c3b571..e1f99594 100644 --- a/docs/guide/media/material-selection.md +++ b/docs/guide/media/material-selection.md @@ -93,7 +93,7 @@ $n_2 = 3\chi^{(3)}/(4\varepsilon_0 c n_0^2)$: optmat's own gas `n2` takes $n_0 = `n2(..., wavelength=)` give the selected values (or take the carrier from `selection=`'s `carrier_wavelength`); a {class}`~solprop.physdata.mixtures.Mixture` evaluates its populations at a carrier with -`gamma3_path(wavelength)`, which {func}`solprop.tools.soliton_params` passes its +`gamma3_path(wavelength)`, which {func}`solprop.tools.capillary.soliton_params` passes its `wavelength`. ```{code-block} python diff --git a/docs/guide/setup/recipes.md b/docs/guide/setup/recipes.md index 145a5b25..0528562d 100644 --- a/docs/guide/setup/recipes.md +++ b/docs/guide/setup/recipes.md @@ -251,7 +251,7 @@ energy = {target = "soliton_order", order = 4} | `soliton_order` | `order` | a pulse's `energy` or `peak_power`, as `energy` or `pulses[0].energy` | A pin on the guide or fill needs `search`, the range its solver -({py:func}`solprop.tools.solve_parameter`) samples, in the keyword's unit; it is solved +({py:func}`solprop.tools.design.solve_parameter`) samples, in the keyword's unit; it is solved on the launch mode (the first) at the entrance. A soliton-order pin is closed form, on that mode with the fill's Kerr coefficient, after the guide's pin. So at most one parameter of the guide or fill is pinned, then the pulses; a pinned value is not also @@ -260,7 +260,7 @@ pinned. `root` chooses among several roots: `"lowest"` (the default), `"highest" index. `recipe.resolve()` returns the recipe with each pin replaced by its value, and -`recipe.pin_roots("pressure")` the solver's {py:class}`~solprop.tools.DesignRoots`: every +`recipe.pin_roots("pressure")` the solver's {py:class}`~solprop.tools.design.DesignRoots`: every root, or none with the range the target does reach, for a front end to offer. A target not met in its range is an issue of `check()` on the pinned path. A build solves the pins (each scan point solves its own), a run records both the authored recipe and the @@ -268,7 +268,7 @@ resolved one, and `to_python()` writes the solver calls ({py:func}`~solprop.recipe.pins.solve_pin`, {py:func}`~solprop.recipe.pins.solve_pulse`) before the route's. Solving a guide's pin costs a few hundred evaluations of one mode, about a second for a capillary. A design that -sets two values at once, such as {py:func}`~solprop.tools.capillary_design`, is not a pin: +sets two values at once, such as {py:func}`~solprop.tools.capillary.capillary_design`, is not a pin: call it and write its values in `[build]`. ## Previews diff --git a/docs/guide/workflows/design-tools.md b/docs/guide/workflows/design-tools.md index 5745f91c..49bb5778 100644 --- a/docs/guide/workflows/design-tools.md +++ b/docs/guide/workflows/design-tools.md @@ -44,15 +44,15 @@ The natural width $\tau_0$ and the peak power $P_0$ depend on the pulse shape: - :math:`E/(\sqrt\pi\,\tau_0)` ``` -One shape is used through the whole chain ({py:func}`~solprop.tools.pulse_width`, -{py:func}`~solprop.tools.pulse_peak_power`, -{py:func}`~solprop.tools.dispersion_length`, -{py:func}`~solprop.tools.soliton_order`, {py:func}`~solprop.tools.fission_length`), so a +One shape is used through the whole chain ({py:func}`~solprop.tools.pulse.pulse_width`, +{py:func}`~solprop.tools.pulse.pulse_peak_power`, +{py:func}`~solprop.tools.soliton.dispersion_length`, +{py:func}`~solprop.tools.soliton.soliton_order`, {py:func}`~solprop.tools.soliton.fission_length`), so a Gaussian's soliton order is computed from the Gaussian width and the Gaussian peak power throughout. -{py:func}`~solprop.tools.soliton_params` collects all of it for any mode and any fill, -and {py:func}`~solprop.tools.capillary_params` builds the capillary mode for you, exactly +{py:func}`~solprop.tools.capillary.soliton_params` collects all of it for any mode and any fill, +and {py:func}`~solprop.tools.capillary.capillary_params` builds the capillary mode for you, exactly as {py:func}`~solprop.driver.interface.prop_capillary` would for the same arguments: ```{code-block} python @@ -83,7 +83,7 @@ CONSTITUENTS (unchirped NLSE): ``` The result is a frozen -{py:class}`~solprop.tools.SolitonParams` whose fields are SI and spelled out +{py:class}`~solprop.tools.capillary.SolitonParams` whose fields are SI and spelled out (`soliton_order`, `fission_length`, `critical_power`, ...). Three of its entries need a word of explanation. @@ -111,13 +111,13 @@ xenon — reads as no zero, and the search raises. Use the design tools' search case. ``` -**The critical power** is {py:func}`~solprop.tools.self_focusing_power`, +**The critical power** is {py:func}`~solprop.tools.beam.self_focusing_power`, $P_{\rm cr}=1.86226\,\lambda^2/(4\pi n_0n_2)$ (Fibich and Gaeta, Opt. Lett. **25**, 335 (2000)). It is {py:func}`solprop.spatial.critical_power` evaluated at `index=n0`: both measure the physical axial power in the medium, and the package has one Townes constant. -{py:func}`~solprop.tools.gas_ratio` compares two gases at one wavelength, pressure and +{py:func}`~solprop.tools.capillary.gas_ratio` compares two gases at one wavelength, pressure and temperature — the ratio of their bulk dispersions and of their Kerr responses, by name — for the "what pressure of argon matches this helium" question. @@ -126,7 +126,7 @@ for the "what pressure of argon matches this helium" question. The conventional $N=\sqrt{L_D/L_{NL}}$ is a continuous measure of nonlinearity. It is not generally the number of discrete solitons. In particular, a Gaussian with conventional $N=1$ is not a fundamental sech soliton. -{py:func}`~solprop.tools.soliton_content` calculates the count and the largest +{py:func}`~solprop.tools.soliton.soliton_content` calculates the count and the largest constituent for an **unchirped** sech or Gaussian input in anomalous dispersion. `SolitonParams.soliton_content` exposes the same result; in normal dispersion it is `None`, while the conventional design numbers remain available. @@ -209,10 +209,10 @@ $$ - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - \tfrac12\gamma P_0 = 0 . $$ -{py:func}`~solprop.tools.phase_mismatch` is this function. -{py:func}`~solprop.tools.lambda_rdw` finds its longest root inside a wavelength window — +{py:func}`~solprop.tools.phase_matching.phase_mismatch` is this function. +{py:func}`~solprop.tools.phase_matching.lambda_rdw` finds its longest root inside a wavelength window — by default 100 nm to 0.9 of the pump wavelength, where that is the short-wavelength wave -nearest the pump — and {py:func}`~solprop.tools.lambda_rdw_all` every root. +nearest the pump — and {py:func}`~solprop.tools.phase_matching.lambda_rdw_all` every root. With pulse data, the default `nonlinear_phase="largest"` uses the phase of the largest constituent calculated above: supply input `peak_power`, `gamma`, `fwhm` @@ -272,9 +272,9 @@ estimate with the linear condition. Two details of the search. The mismatch is sampled on 2048 frequencies across the window and every sign change is refined with Brent's method, so -{py:func}`~solprop.tools.lambda_rdw_all` returns every root in the window in order — a +{py:func}`~solprop.tools.phase_matching.lambda_rdw_all` returns every root in the window in order — a mode with two zero-dispersion points has a dispersive wave on each side of the pump, and -a window that reaches to the red finds both — while {py:func}`~solprop.tools.lambda_rdw` +a window that reaches to the red finds both — while {py:func}`~solprop.tools.phase_matching.lambda_rdw` returns the longest of them, which on the default window is the short-wavelength wave nearest the pump. And a window with no root **raises** {py:class}`~solprop.exceptions.ToolsError`, naming the window and the sign of the mismatch @@ -291,10 +291,10 @@ rests on an extrapolated cladding index. ## The pressure that tunes it -{py:func}`~solprop.tools.pressure_rdw` inverts the calculation: the pressure at which +{py:func}`~solprop.tools.design.pressure_rdw` inverts the calculation: the pressure at which the dispersive wave is phase matched at a target wavelength, by Brent's method between $10^{-6}$ bar and `pressure_max`. With `peak_power` the nonlinear coefficient follows the -pressure inside the search. {py:func}`~solprop.tools.pressure_zdw` does the same for the +pressure inside the search. {py:func}`~solprop.tools.design.pressure_zdw` does the same for the zero-dispersion wavelength. The capillary is built once and re-evaluated at each density through its parameterised form, rather than rebuilt with its equation of state at every step, so a search costs milliseconds. @@ -322,8 +322,8 @@ can be missed; narrowing the bracket improves their resolution. The usual question runs the other way round: given a core radius, a gas and a pulse, what pressure and energy give a chosen zero-dispersion wavelength and a chosen soliton? -{py:func}`~solprop.tools.capillary_design` answers it in two steps. The pressure -places the zero ({py:func}`~solprop.tools.pressure_zdw`) and does not depend on the +{py:func}`~solprop.tools.capillary.capillary_design` answers it in two steps. The pressure +places the zero ({py:func}`~solprop.tools.design.pressure_zdw`) and does not depend on the energy. At that pressure $\beta_2$ and $\gamma$ are fixed, and $N^2=\gamma P_0\tau_0^2/|\beta_2|$ is linear in the peak power, so the energy follows in closed form: @@ -369,8 +369,8 @@ its own natural width. 'N = 1.5491, eta = 1.000000' ``` -The result is the {py:class}`~solprop.tools.SolitonParams` of -{py:func}`~solprop.tools.capillary_params` at the designed pressure and energy, with +The result is the {py:class}`~solprop.tools.capillary.SolitonParams` of +{py:func}`~solprop.tools.capillary.capillary_params` at the designed pressure and energy, with the mode. `pressure_zdw` sets the limits: a single built-in gas, the `HE_1m` mode. ## Antiresonant fibres from measured geometry @@ -424,7 +424,7 @@ window when close resonances cannot be resolved by the 2048-point sampling. ## Any guide, any parameter -The pressure tools are written for the capillary. {py:func}`~solprop.tools.solve_parameter` +The pressure tools are written for the capillary. {py:func}`~solprop.tools.design.solve_parameter` is their general form: it takes a **builder**, a function that returns the mode at a trial value of any parameter (a radius, a pressure, a core size, a wall thickness), a search range, and a target, and returns every value in the range that meets the target. @@ -442,7 +442,7 @@ search range, and a target, and returns every value in the range that meets the ['89.49 um'] ``` -{py:func}`~solprop.tools.radius_zdw` is this search for the capillary, the radius +{py:func}`~solprop.tools.design.radius_zdw` is this search for the capillary, the radius counterpart of `pressure_zdw`, with the same keywords and `materials=`: `radius_zdw("He", 1.0, 400e-9, cladding=1.45)` is the same 89.49 µm. @@ -456,21 +456,21 @@ There are three targets, each a frozen dataclass whose residual the search brack * - target - residual on the trial mode - the builder returns - * - :py:class:`~solprop.tools.ZDW` ``(wavelength)`` + * - :py:class:`~solprop.tools.design.ZDW` ``(wavelength)`` - :math:`\beta_2` at the wavelength - the mode - * - :py:class:`~solprop.tools.RDW` ``(pump, rdw, peak_power, fwhm, shape, nonlinear_phase)`` + * - :py:class:`~solprop.tools.design.RDW` ``(pump, rdw, peak_power, fwhm, shape, nonlinear_phase)`` - the phase mismatch at ``rdw``, exactly as ``pressure_rdw`` evaluates it; none where the largest constituent does not exist - the mode, or ``(mode, gamma)`` with a ``peak_power`` - * - :py:class:`~solprop.tools.SolitonOrder` ``(order, pulse)`` + * - :py:class:`~solprop.tools.design.SolitonOrder` ``(order, pulse)`` - the pulse's soliton order less ``order``, at the pulse's wavelength - ``(mode, gamma)`` ``` A mode need not know its fill's Kerr response, so a target that needs the nonlinear coefficient asks the builder for it beside the mode, from -{py:func}`~solprop.tools.nonlinear_coefficient`; a builder that returns one serves a +{py:func}`~solprop.tools.soliton.nonlinear_coefficient`; a builder that returns one serves a linear target too, which ignores it. The search evaluates each mode at the launch position `z` (zero by default). @@ -487,7 +487,7 @@ refinements, so a builder that re-evaluates one mode, as above, costs millisecon value, while one that constructs a new capillary costs about 20 ms, and an antiresonant or tabulated guide more. -**Every root.** The result is a {py:class}`~solprop.tools.DesignRoots`: the roots in +**Every root.** The result is a {py:class}`~solprop.tools.design.DesignRoots`: the roots in ascending order, the range, the target and the number of builds. A design can have more than one answer. The soliton order of a fixed pulse diverges at the pressure that puts the zero dispersion at the pump, and is the conventional measure on both sides of @@ -538,8 +538,8 @@ refusal says the quantity is undefined across the range. **The pulse for an order.** The other soliton design holds the mode and asks for the pulse. $N^2=\gamma P_0\tau_0^2/|\beta_2|$ is linear in the peak power, so -{py:func}`~solprop.tools.soliton_peak_power` is closed form, and -{py:func}`~solprop.tools.pulse_energy` turns the peak power into an energy, as +{py:func}`~solprop.tools.soliton.soliton_peak_power` is closed form, and +{py:func}`~solprop.tools.pulse.pulse_energy` turns the peak power into an energy, as `capillary_design` does at its designed pressure: ```{code-block} python @@ -561,10 +561,10 @@ default; a design computed from other data would name a pressure the run does no reproduce. Every design tool that computes a gas or wall index or a Kerr coefficient from a name therefore takes the same `materials=` keyword, with the same default, `MaterialSelection()` — -{py:func}`~solprop.tools.soliton_params`, {py:func}`~solprop.tools.capillary_params`, -{py:func}`~solprop.tools.capillary_design`, -{py:func}`~solprop.tools.gas_ratio`, {py:func}`~solprop.tools.pressure_zdw`, -{py:func}`~solprop.tools.pressure_rdw` and {py:func}`~solprop.tools.radius_zdw` — and +{py:func}`~solprop.tools.capillary.soliton_params`, {py:func}`~solprop.tools.capillary.capillary_params`, +{py:func}`~solprop.tools.capillary.capillary_design`, +{py:func}`~solprop.tools.capillary.gas_ratio`, {py:func}`~solprop.tools.design.pressure_zdw`, +{py:func}`~solprop.tools.design.pressure_rdw` and {py:func}`~solprop.tools.design.radius_zdw` — and resolves exactly as the capillary driver does: a name, a fraction mapping and `"air"` (nitrogen and oxygen) through the selection, the Kerr coefficient at the tool's pump wavelength (a selection naming diff --git a/docs/guide/workflows/interactive.md b/docs/guide/workflows/interactive.md index ec7d5489..2525de19 100644 --- a/docs/guide/workflows/interactive.md +++ b/docs/guide/workflows/interactive.md @@ -190,7 +190,7 @@ ModeCollection: 2 modes ``` An {py:class}`~solprop.output.hdf5.HDF5Output` says whether its file is open, closed, -read-only or gone. A {py:class}`~solprop.tools.SolitonParams` prints the block shown in +read-only or gone. A {py:class}`~solprop.tools.capillary.SolitonParams` prints the block shown in [design tools](design-tools.md), and a {py:class}`~solprop.processing.compression.Compression` says `NOT CLEAN` when its search did not converge inside its bounds. diff --git a/src/solprop/modes/guide.py b/src/solprop/modes/guide.py index 7f1c1b65..6c706f18 100644 --- a/src/solprop/modes/guide.py +++ b/src/solprop/modes/guide.py @@ -1200,8 +1200,8 @@ class ProviderChannelMode(Displayed): five-point differences on the reference parabolic sector it is 4.4e-13 relative at nine knots over a +-2 % band. ``beta_2`` -- what :func:`solprop.modes.base.beta2` differences out of that derivative, and so what - :func:`solprop.modes.base.zdw`, :func:`solprop.tools.dispersion_length` and - :func:`solprop.tools.soliton_order` are given for this channel -- is + :func:`solprop.modes.base.zdw`, :func:`solprop.tools.soliton.dispersion_length` and + :func:`solprop.tools.soliton.soliton_order` are given for this channel -- is **diagnostic only and uncertified**: on the same sector and the same held-out frequencies it is 2.8e-2 relative at nine knots, 3.2e-3 at seventeen and 2.1e-3 at thirty-three over +-2 %, and worse than unity over a +-20 % band at any knot diff --git a/src/solprop/recipe/pins.py b/src/solprop/recipe/pins.py index e271867a..e95722d8 100644 --- a/src/solprop/recipe/pins.py +++ b/src/solprop/recipe/pins.py @@ -5,7 +5,7 @@ """Pins: a recipe value set from a design target instead of a literal (SEP 41, S1). A recipe's ``[pins]`` table sets a whole value from a target of -:func:`solprop.tools.solve_parameter`:: +:func:`solprop.tools.design.solve_parameter`:: [pins] pressure = {target = "zdw", wavelength = "800 nm", search = ["0.1 bar", "10 bar"]} @@ -368,7 +368,7 @@ def solve_pin( The route is built once from `keywords`, with the parameter at the middle of `search`, and its launch mode is re-evaluated at each trial value (no further - builds), as :func:`solprop.tools.pressure_zdw` does; a target that needs the + builds), as :func:`solprop.tools.design.pressure_zdw` does; a target that needs the fill's Kerr coefficient is given it at each trial value. This is the call the Python export of a pinned recipe writes before the route's call. @@ -440,7 +440,7 @@ def solve_pulse( """Return the energy or peak power that gives a pulse a soliton order. Closed form on the launch mode at the launch position, with the fill's Kerr - coefficient (:func:`solprop.tools.soliton_peak_power`). The route is built once, + coefficient (:func:`solprop.tools.soliton.soliton_peak_power`). The route is built once, the pulse at a placeholder, for its mode. This is the call the Python export of a recipe with a ``soliton_order`` pin writes before the route's call. diff --git a/src/solprop/recipe/preview.py b/src/solprop/recipe/preview.py index 4fa24f1a..acca40a9 100644 --- a/src/solprop/recipe/preview.py +++ b/src/solprop/recipe/preview.py @@ -78,16 +78,16 @@ class ModePreview: gamma : numpy.ndarray or None The nonlinear coefficient ``n2 omega / (c A_eff)`` in rad/(W m), with the fill's ``n2`` at the grid's reference wavelength, the carrier its Kerr data - are taken at (:func:`solprop.tools.nonlinear_coefficient` at each + are taken at (:func:`solprop.tools.soliton.nonlinear_coefficient` at each frequency); ``None`` where the mode has no fill or the fill no Kerr data. phase_mismatch : numpy.ndarray or None The phase mismatch in rad/m between a dispersive wave in this mode and a soliton in the launch mode (the set's first) at the pump - (:func:`solprop.tools.phase_mismatch`); ``None`` without a pump. See + (:func:`solprop.tools.phase_matching.phase_mismatch`); ``None`` without a pump. See :func:`modes` for the pump and the nonlinear term. rdw : tuple of float The phase-matched wavelengths in m, shortest first, between the band's - short edge and 0.9 of the pump (:func:`solprop.tools.lambda_rdw_all`); + short edge and 0.9 of the pump (:func:`solprop.tools.phase_matching.lambda_rdw_all`); empty without a pump or a root. """ @@ -210,7 +210,7 @@ def modes(recipe: "Recipe", z: float | None = None) -> ModeSetPreview: mode (the launch mode's own for the first; intermodal for the rest). With an unchirped sech or Gaussian pulse given by its FWHM and its energy or peak power, and a fill with Kerr data, the soliton's nonlinear wavenumber is that of its - largest constituent (:func:`solprop.tools.phase_mismatch`'s default); otherwise, + largest constituent (:func:`solprop.tools.phase_matching.phase_mismatch`'s default); otherwise, and for a pump in normal dispersion, which launches no soliton, the condition is the linear one. """ @@ -284,7 +284,7 @@ def _pump( # noqa: PLR0911 - one return per case of the linear condition - ``z``: the position, m: the fill's coefficient, the mode's area and its dispersion are taken there, as the mismatch is. - Returns: the pump wavelength in m (``None`` without a numeric ``wavelength``), and - the keywords :func:`solprop.tools.phase_mismatch` takes for the nonlinear term + the keywords :func:`solprop.tools.phase_matching.phase_mismatch` takes for the nonlinear term (``peak_power``, ``gamma``, ``fwhm``, ``shape``), empty for the linear condition. The nonlinear coefficient is the recipe's ``gamma`` where it gives one (the GNLSE), else the launch mode's fill's at `z`. The condition is linear for a diff --git a/src/solprop/tools/__init__.py b/src/solprop/tools/__init__.py index b34c30c9..e5fbcaad 100644 --- a/src/solprop/tools/__init__.py +++ b/src/solprop/tools/__init__.py @@ -16,9 +16,10 @@ N = \sqrt{L_D/L_{NL}},\quad L_{\rm fiss} = L_D/N, -with the natural width :math:`\tau_0` and the peak power :math:`P_0` of the chosen -pulse shape (:func:`pulse_width`, :func:`pulse_peak_power`). One shape runs through the -whole chain, so the Gaussian numbers are never computed with a ``sech`` width. +with the natural width :math:`\tau_0` and the peak power :math:`P_0` of the chosen pulse +shape (:func:`~solprop.tools.pulse.pulse_width`, +:func:`~solprop.tools.pulse.pulse_peak_power`). One shape runs through the whole chain, +so the Gaussian numbers are never computed with a ``sech`` width. A dispersive wave is phase matched to a soliton at the frequencies where @@ -26,11 +27,11 @@ \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} -vanishes (:func:`phase_mismatch`). The last term is the soliton's nonlinear wavenumber -and uses the largest unchirped constituent when pulse data are given -(:func:`soliton_content`). Without pulse data the condition is linear. -``docs/guide/workflows/design-tools.md`` derives both and says what the nonlinear term -assumes. +vanishes (:func:`~solprop.tools.phase_matching.phase_mismatch`). The last term is the +soliton's nonlinear wavenumber and uses the largest unchirped constituent when pulse +data are given (:func:`~solprop.tools.soliton.soliton_content`). Without pulse data the +condition is linear. ``docs/guide/workflows/design-tools.md`` derives both and says what +the nonlinear term assumes. Everything is SI except pressure, which is in bar as at the rest of the user interface (``docs/dev/conventions.md`` section 1). diff --git a/src/solprop/tools/capillary.py b/src/solprop/tools/capillary.py index 1d138d34..7721e304 100644 --- a/src/solprop/tools/capillary.py +++ b/src/solprop/tools/capillary.py @@ -103,7 +103,8 @@ class SolitonParams(Displayed): fwhm : float Full width at half maximum of the power in s. width : float - Natural width ``tau0`` of the shape in s (:func:`pulse_width`). + Natural width ``tau0`` of the shape in s + (:func:`~solprop.tools.pulse.pulse_width`). omega : float Angular frequency of the pump in rad/s. wavelength : float @@ -141,7 +142,7 @@ class SolitonParams(Displayed): of a capillary it is 1.77 times `intensity`. NaN for a mode without a transverse profile (a dispersion-specified one). critical_power : float - Self-focusing power in W (:func:`self_focusing_power`). + Self-focusing power in W (:func:`~solprop.tools.beam.self_focusing_power`). dispersion_length : float In m. nonlinear_length : float @@ -603,18 +604,18 @@ def capillary_design( ) -> SolitonParams: r"""Return the pressure and energy that give a capillary a soliton design. - The inverse of :func:`capillary_params`: the pressure puts the zero-dispersion - point at `zdw_wavelength` (:func:`pressure_zdw`), and the energy then gives the - pulse one soliton target. At that pressure :math:`\beta_2` and :math:`\gamma` do - not depend on the energy and :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|`, so the - peak power is :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. Give exactly one - target: + The inverse of :func:`capillary_params`: the pressure puts the zero-dispersion point + at `zdw_wavelength` (:func:`~solprop.tools.design.pressure_zdw`), and the energy + then gives the pulse one soliton target. At that pressure :math:`\beta_2` and + :math:`\gamma` do not depend on the energy and + :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|`, so the peak power is + :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. Give exactly one target: - `soliton_order`, the conventional :math:`N`; - `soliton_count`, a number :math:`M` of solitons in the scattering content - (:func:`soliton_content`): the pulse area is set to :math:`M\pi`, the middle - of the band of areas with :math:`M` solitons, which is :math:`N = M` for - sech and :math:`N = M\sqrt{\pi/2}` for a Gaussian; + (:func:`~solprop.tools.soliton.soliton_content`): the pulse area is set to + :math:`M\pi`, the middle of the band of areas with :math:`M` solitons, which is + :math:`N = M` for sech and :math:`N = M\sqrt{\pi/2}` for a Gaussian; - `largest_eigenvalue`, the :math:`\eta` of the largest constituent, which fixes its width :math:`\tau_0/(2\eta)`: :math:`N = \eta + 1/2` for sech, and a root of the Gaussian scattering problem otherwise. @@ -624,7 +625,7 @@ def capillary_design( radius : float Core radius in m, > 0. gas : Gas or str - A single built-in gas, as :func:`pressure_zdw` takes it. + A single built-in gas, as :func:`~solprop.tools.design.pressure_zdw` takes it. zdw_wavelength : float Wavelength at which the dispersion should vanish, m. fwhm : float @@ -652,7 +653,8 @@ def capillary_design( Cladding index; ``None`` is fused silica. materials : MaterialSelection or None, optional Design under this selection, as :func:`capillary_params` and - :func:`pressure_zdw` do. ``None`` is ``MaterialSelection()``. + :func:`~solprop.tools.design.pressure_zdw` do. ``None`` is + ``MaterialSelection()``. pressure_max : float, optional Upper end of the pressure bracket in **bar**. diff --git a/src/solprop/tools/design.py b/src/solprop/tools/design.py index cfb66cb0..6ff8de4b 100644 --- a/src/solprop/tools/design.py +++ b/src/solprop/tools/design.py @@ -101,7 +101,7 @@ readily as onto a zero; at a zero the residual it ends on is of the order of the slope times the tolerance, many orders below the ends, and at a pole it diverges past both. A parameter has no integral like ``beta1`` to test the change against (see -:data:`_POLE_FACTOR`), so the residual's own size decides.""" +:data:`~solprop.tools.waveguide._POLE_FACTOR`), so the residual's own size decides.""" def _pressure_root( @@ -601,12 +601,12 @@ def __post_init__(self) -> None: class RDW: """Target a resonant dispersive wave phase matched at a wavelength. - The residual :func:`solve_parameter` brackets is :func:`phase_mismatch` of the - trial mode at `rdw`, in 1/m, exactly as :func:`pressure_rdw` evaluates it: with - `peak_power` the soliton's nonlinear wavenumber is included, with the trial - mode's own nonlinear coefficient, and with the largest constituent (`fwhm` - given) a trial value in normal dispersion or below the first soliton threshold - has no residual and cannot bracket a root. + The residual :func:`solve_parameter` brackets is + :func:`~solprop.tools.phase_matching.phase_mismatch` of the trial mode at `rdw`, in + 1/m, exactly as :func:`pressure_rdw` evaluates it: with `peak_power` the soliton's + nonlinear wavenumber is included, with the trial mode's own nonlinear coefficient, + and with the largest constituent (`fwhm` given) a trial value in normal dispersion + or below the first soliton threshold has no residual and cannot bracket a root. Attributes ---------- @@ -622,7 +622,8 @@ class RDW: shape : {'sech', 'gauss'} Unchirped input shape for the largest constituent. nonlinear_phase : {'largest', 'supplied_power', 'linear'} - The nonlinear phase model, as :func:`phase_mismatch` takes it. + The nonlinear phase model, as + :func:`~solprop.tools.phase_matching.phase_mismatch` takes it. Raises ------ @@ -674,13 +675,14 @@ def __post_init__(self) -> None: class SolitonOrder: """Target a conventional soliton order for a fixed pulse. - The residual :func:`solve_parameter` brackets is :func:`soliton_order` of the - pulse in the trial mode, with the trial mode's own nonlinear coefficient, minus - `order`: a guide or fill parameter is solved for, the pulse is held. The order - is the conventional measure on either side of a zero-dispersion point, so a - search across one may find a root on each side. The opposite question, the - pulse that has an order on a given mode, is closed form: - :func:`soliton_peak_power` and :func:`pulse_energy`. + The residual :func:`solve_parameter` brackets is + :func:`~solprop.tools.soliton.soliton_order` of the pulse in the trial mode, with + the trial mode's own nonlinear coefficient, minus `order`: a guide or fill parameter + is solved for, the pulse is held. The order is the conventional measure on either + side of a zero-dispersion point, so a search across one may find a root on each + side. The opposite question, the pulse that has an order on a given mode, is closed + form: :func:`~solprop.tools.soliton.soliton_peak_power` and + :func:`~solprop.tools.pulse.pulse_energy`. Attributes ---------- @@ -760,7 +762,8 @@ def fwhm(self) -> float: # numpydoc ignore=RT01 def peak_power(self) -> float: # numpydoc ignore=RT01 """The pulse's peak power, W. - The pulse's own, or that of its energy (:func:`pulse_peak_power`). + The pulse's own, or that of its energy + (:func:`~solprop.tools.pulse.pulse_peak_power`). """ if self.pulse.peak_power is not None: return self.pulse.peak_power @@ -778,7 +781,8 @@ def peak_power(self) -> float: # numpydoc ignore=RT01 DesignBuild = Callable[[float], Mode | tuple[Mode, float]] """A builder: a trial value of the parameter to a mode, or to a mode and the fill's -nonlinear coefficient in rad/(W m) (:func:`nonlinear_coefficient`).""" +nonlinear coefficient in rad/(W m) +(:func:`~solprop.tools.soliton.nonlinear_coefficient`).""" @dataclass(frozen=True) @@ -1016,7 +1020,8 @@ def _target_residual( def _rdw_window(target: RDW) -> tuple[float, float]: """Return the window an RDW target's attainable wavelengths are searched in. - - Returns: :func:`lambda_rdw`'s default window, 100 nm to 0.9 of the pump, when the + - Returns: :func:`~solprop.tools.phase_matching.lambda_rdw`'s default window, 100 nm + to 0.9 of the pump, when the target is on the blue side of the pump, widened to include it; and the mirror of it, 1/0.9 of the pump to 3 um, on the red side. """ @@ -1121,10 +1126,11 @@ def solve_parameter( Parameters ---------- build : callable - ``build(value)`` returns the mode at a trial value of the parameter, or - ``(mode, gamma)`` with the fill's nonlinear coefficient in rad/(W m) - (:func:`nonlinear_coefficient`), which an RDW target with a peak power and a - soliton-order target need: a mode need not know its fill's Kerr response. + ``build(value)`` returns the mode at a trial value of the parameter, or ``(mode, + gamma)`` with the fill's nonlinear coefficient in rad/(W m) + (:func:`~solprop.tools.soliton.nonlinear_coefficient`), which an RDW target with + a peak power and a soliton-order target need: a mode need not know its fill's + Kerr response. search : tuple of float Finite ordered range of the parameter, ``(low, high)``, in its unit. target : ZDW, RDW or SolitonOrder diff --git a/src/solprop/tools/phase_matching.py b/src/solprop/tools/phase_matching.py index e9fd55b2..c430bf14 100644 --- a/src/solprop/tools/phase_matching.py +++ b/src/solprop/tools/phase_matching.py @@ -202,7 +202,7 @@ def phase_mismatch( wavenumber; both ``None`` (the default) gives the linear condition. gamma : float or None, optional Nonlinear coefficient of the soliton's mode in rad/(W m) - (:func:`nonlinear_coefficient`). + (:func:`~solprop.tools.soliton.nonlinear_coefficient`). fwhm : float or None, optional Input power FWHM in s, positive; required with power for the default phase. shape : {'sech', 'gauss'}, optional @@ -224,13 +224,12 @@ def phase_mismatch( If pulse arguments are incomplete or contradictory, or the largest constituent does not exist in anomalous dispersion. - Notes - ----- - The default nonlinear term is gamma times the largest constituent's power / 2 - (:func:`soliton_content`). The supplied-power option treats the quoted power as - a fundamental soliton, advancing as exp(i gamma P0 z / 2) (Akhmediev and - Karlsson, Phys. Rev. A 51, 2602 (1995)). Both are local estimates; loss, - higher-order dispersion, Raman, chirp and plasma evolution are not included. + Notes ----- The default nonlinear term is gamma times the largest constituent's + power / 2 (:func:`~solprop.tools.soliton.soliton_content`). The supplied-power + option treats the quoted power as a fundamental soliton, advancing as exp(i gamma P0 + z / 2) (Akhmediev and Karlsson, Phys. Rev. A 51, 2602 (1995)). Both are local + estimates; loss, higher-order dispersion, Raman, chirp and plasma evolution are not + included. Examples -------- diff --git a/src/solprop/tools/soliton.py b/src/solprop/tools/soliton.py index 18e0b161..78cd2b0e 100644 --- a/src/solprop/tools/soliton.py +++ b/src/solprop/tools/soliton.py @@ -49,7 +49,7 @@ def dispersion_length(fwhm: float, beta2: float, shape: PulseShape = "sech") -> beta2 : float Group-velocity dispersion in s^2/m, non-zero. shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. + The pulse shape; see :func:`~solprop.tools.pulse.pulse_width`. Returns ------- @@ -154,11 +154,11 @@ def soliton_peak_power( ) -> float: r"""Return the peak power that gives a soliton order. - The inverse of :func:`soliton_order` at fixed dispersion, nonlinear coefficient - and width: :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|` is linear in the peak - power, so :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. :func:`pulse_energy` - turns it into an energy. On a mode, take `beta2` from - :func:`solprop.modes.base.beta2` at the pump and `gamma` from + The inverse of :func:`soliton_order` at fixed dispersion, nonlinear coefficient and + width: :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|` is linear in the peak power, so + :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. + :func:`~solprop.tools.pulse.pulse_energy` turns it into an energy. On a mode, take + `beta2` from :func:`solprop.modes.base.beta2` at the pump and `gamma` from :func:`nonlinear_coefficient`, both at the launch position. Parameters @@ -357,7 +357,7 @@ def fission_length( beta2 : float Group-velocity dispersion in s^2/m, non-zero. shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. + The pulse shape; see :func:`~solprop.tools.pulse.pulse_width`. Returns ------- diff --git a/src/solprop/tools/waveguide.py b/src/solprop/tools/waveguide.py index e5eec2df..4e3e4834 100644 --- a/src/solprop/tools/waveguide.py +++ b/src/solprop/tools/waveguide.py @@ -7,7 +7,7 @@ A fill as the run interfaces accept it, resolved to an optmat mixture; its Kerr coefficient; the default cladding; and the zero-dispersion point of a mode nearest a -wavelength. Every design search evaluates its probes inside :func:`_probing`. +wavelength. Every design search evaluates its probes inside ``_probing``. """ import math From 3fe5d7cc9f704d57e92655fb51487207ddcf4848 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:55:47 +0000 Subject: [PATCH 07/42] Drive RunMetadata's stored JSON from its field metadata The resume key's entries, the stored document and the reader come from each field's metadata instead of three hand lists; the stored format is unchanged (an older document reads back and rewrites to the same text). Co-Authored-By: Claude Opus 5.5 --- src/solprop/output/metadata.py | 275 +++++++++++++++++---------------- 1 file changed, 143 insertions(+), 132 deletions(-) diff --git a/src/solprop/output/metadata.py b/src/solprop/output/metadata.py index e18d81ea..b59a31d2 100644 --- a/src/solprop/output/metadata.py +++ b/src/solprop/output/metadata.py @@ -59,7 +59,6 @@ _ArrayReference, _rebuild, _resolved, - _stored_entry, serialise, ) from solprop.physdata.selection import MaterialManifest, hashed_document @@ -180,25 +179,6 @@ def _refuse_schema(declared: object) -> None: raise OutputError(msg) -_HASHED_KEYS: tuple[str, ...] = ( - "schema_version", - "simulation_kind", - "grid", - "modes", - "configuration", - "noise", - "stepper", - "spatial", - "power_convention", - "modal", - "materials", - "initial_z", - "tdse", - "quadrature", -) -"""The entries of :meth:`RunMetadata.hashed`, in the order it builds them.""" - - def _stored_hashed(document: Mapping[str, JsonValue]) -> dict[str, JsonValue]: """Return the hashed part of a stored record, as the stored text holds it. @@ -208,7 +188,7 @@ def _stored_hashed(document: Mapping[str, JsonValue]) -> dict[str, JsonValue]: (:func:`solprop.physdata.selection.hashed_document`). An entry the document lacks stays absent, so the record digests to the stamp its writer computed. """ - hashed = {name: document[name] for name in _HASHED_KEYS if name in document} + hashed = {name: document[name] for name in _HASHED_ENTRIES if name in document} modal = hashed.get("modal") if isinstance(modal, dict): hashed["modal"] = { @@ -412,6 +392,27 @@ def _material_rows(manifest: MaterialManifest) -> list[tuple[str, object]]: return [("materials", f"optmat {manifest.optmat_version}: {names}")] +def _optional[T](build: type[T], *path: str) -> Callable[[object], T | None]: + """Return the reader of an optional stored record: ``None`` stays ``None``.""" + return lambda value: ( + None + if value is None + else _rebuild(build, cast("Mapping[str, object]", value), path) + ) + + +def _modes(value: object) -> tuple[ModeRecord, ...]: + """Read the stored mode records, each named by its index in a refusal.""" + return tuple( + _rebuild(ModeRecord, one, ("modes", index)) + for index, one in enumerate(cast("list[Mapping[str, object]]", value)) + ) + + +_HASHED = {"hashed": True} +"""A :class:`RunMetadata` field the resume key covers, serialised as stored.""" + + @dataclass(frozen=True, eq=False, kw_only=True) class RunMetadata(Displayed): """Everything needed to say what produced a file, and to refuse a wrong resume. @@ -543,29 +544,86 @@ class RunMetadata(Displayed): 'optics' """ - grid: GridRecord - modes: tuple[ModeRecord, ...] - simulation_kind: SimulationKind - configuration: Mapping[str, object] - stepper: StepperRecord - noise: NoiseRecord | None = None - backend: BackendRecord | None = None - versions: Mapping[str, str] = dataclasses.field(default_factory=package_versions) - git: GitProvenance | None = dataclasses.field(default_factory=GitProvenance.detect) - created: str = "" - schema_version: int = SCHEMA_VERSION - spectral_convention: str = SPECTRAL_CONVENTION - field_layout: str = FIELD_LAYOUT - spatial: Mapping[str, object] | None = None - modal: Mapping[str, object] | None = None - materials: MaterialManifest = dataclasses.field(default_factory=MaterialManifest) - initial_z: float = 0.0 - tdse: Mapping[str, object] | None = None - quadrature: Mapping[str, object] | None = None + # Each field is stored under its own name, serialised (to_json); `hashed` puts it + # in the resume key, `key` gives it a hashed form of its own and `store` a stored + # one, `read` rebuilds it from the stored form (from_json), and `absent` is what a + # record written without it reads as, where that is not the field's default. A + # field without a default is required in a stored record. + grid: GridRecord = dataclasses.field( + metadata={ + **_HASHED, + "read": lambda value: _rebuild(GridRecord, value, ("grid",)), + } + ) + modes: tuple[ModeRecord, ...] = dataclasses.field( + metadata={**_HASHED, "read": _modes} + ) + simulation_kind: SimulationKind = dataclasses.field(metadata=_HASHED) + configuration: Mapping[str, object] = dataclasses.field(metadata=_HASHED) + stepper: StepperRecord = dataclasses.field( + metadata={**_HASHED, "read": StepperRecord.from_record} + ) + noise: NoiseRecord | None = dataclasses.field( + default=None, metadata={**_HASHED, "read": _optional(NoiseRecord, "noise")} + ) + backend: BackendRecord | None = dataclasses.field( + default=None, metadata={"read": _optional(BackendRecord)} + ) + versions: Mapping[str, str] = dataclasses.field( + default_factory=package_versions, metadata={"absent": {}} + ) + git: GitProvenance | None = dataclasses.field( + default_factory=GitProvenance.detect, + metadata={"absent": None, "read": _optional(GitProvenance)}, + ) + created: str = dataclasses.field(default="", metadata={"read": str}) + schema_version: int = dataclasses.field(default=SCHEMA_VERSION, metadata=_HASHED) + spectral_convention: str = dataclasses.field( + default=SPECTRAL_CONVENTION, metadata={"read": str} + ) + field_layout: str = dataclasses.field(default=FIELD_LAYOUT, metadata={"read": str}) + spatial: Mapping[str, object] | None = dataclasses.field( + default=None, metadata=_HASHED + ) + # The whole identity is stored; the key leaves out the accessor list, which is + # the build's capability and not the run's description (_hashed_modal). + modal: Mapping[str, object] | None = dataclasses.field( + default=None, metadata={**_HASHED, "key": _hashed_modal} + ) + # The whole manifest, measurements included, is stored; the key takes its + # identities and policies, and a run of solprop's own materials keys on `None`. + materials: MaterialManifest = dataclasses.field( + default_factory=MaterialManifest, + metadata={ + **_HASHED, + "key": lambda manifest: serialise(manifest.hashed()), + "store": lambda manifest: serialise(manifest.to_dict()), + "absent": None, + "read": MaterialManifest.from_dict, + }, + ) + initial_z: float = dataclasses.field(default=0.0, metadata=_HASHED) + tdse: Mapping[str, object] | None = dataclasses.field( + default=None, metadata=_HASHED + ) + quadrature: Mapping[str, object] | None = dataclasses.field( + default=None, metadata=_HASHED + ) + # Derived from the hashed problem, so stored beside the key, not in it: a resume + # on another build may evaluate the frame to a different last bit. reference: Mapping[str, object] | None = None + # The transverse boundary's derived values (SEP 40), from a root-finder: beside + # the key, so that a resume on another build is not refused. transverse: Mapping[str, object] | None = None - noise_resolution: NoiseResolution | None = None - defaults: DefaultsResolution | None = None + # How the model was chosen, beside the key: the resolved model is in the hashed + # noise record already, and a request is not part of the physics. + noise_resolution: NoiseResolution | None = dataclasses.field( + default=None, metadata={"read": _optional(NoiseResolution)} + ) + # Which numerics took the route's default, beside the key for the same reason. + defaults: DefaultsResolution | None = dataclasses.field( + default=None, metadata={"read": _optional(DefaultsResolution)} + ) _stored: dict[str, JsonValue] | None = dataclasses.field( default=None, init=False, repr=False, compare=False, metadata={"record": False} ) @@ -741,26 +799,13 @@ def _hashed_record(self) -> dict[str, JsonValue]: """ if self._stored is not None: return self._stored - return { - "schema_version": self.schema_version, - "simulation_kind": self.simulation_kind, - "grid": serialise(self.grid), - "modes": serialise(list(self.modes)), - "configuration": serialise(dict(self.configuration)), - "noise": serialise(self.noise), - "stepper": serialise(self.stepper), - "spatial": serialise(self.spatial), - "power_convention": self.power_convention, - # All of the general-guide identity but the accessor list, which is the - # build's capability and not the run's description (_hashed_modal). - "modal": _hashed_modal(self.modal), - # The material manifest's identities and policies; a run of solprop's - # own materials keys on `None`. - "materials": serialise(self.materials.hashed()), - "initial_z": self.initial_z, - "tdse": serialise(self.tdse), - "quadrature": serialise(self.quadrature), + record: dict[str, JsonValue] = { + entry.name: entry.metadata.get("key", serialise)(getattr(self, entry.name)) + for entry in dataclasses.fields(self) + if entry.metadata.get("hashed") } + record["power_convention"] = self.power_convention + return record def configuration_hash(self) -> str: """Return the resume key: a digest of everything that was computed. @@ -1002,30 +1047,15 @@ def _document(self) -> dict[str, JsonValue]: - Returns: the hashed part and what is stored beside it, JSON-ready. """ document = self.hashed() - # The whole identity, including the accessor list `hashed` leaves out: what a - # file records and what its resume key is taken over are two different - # questions, and the published accessors answer only the first - # (:func:`_hashed_modal`). - document["modal"] = serialise(self.modal) - # The whole manifest, measurements included, beside its hashed part. - document["materials"] = serialise(self.materials.to_dict()) - # Derived from the hashed problem, so stored beside the key, not in it: a - # resume on another build may evaluate the frame to a different last bit. - document["reference"] = serialise(self.reference) - # The transverse boundary's derived values (SEP 40), from a root-finder: - # beside the key, so that a resume on another build is not refused. - document["transverse"] = serialise(self.transverse) - # How the model was chosen, beside the key: the resolved model is in the - # hashed noise record already, and a request is not part of the physics. - document["noise_resolution"] = serialise(self.noise_resolution) - # Which numerics took the route's default, beside the key for the same reason. - document["defaults"] = serialise(self.defaults) - document["spectral_convention"] = self.spectral_convention - document["field_layout"] = self.field_layout - document["backend"] = serialise(self.backend) - document["versions"] = serialise(dict(self.versions)) - document["git"] = serialise(self.git) - document["created"] = self.created + # A hashed field is stored as hashed unless the key takes a form of its own: + # what a file records and what its resume key is taken over are two + # different questions. + for entry in dataclasses.fields(self): + if entry.init and ( + not entry.metadata.get("hashed") or "key" in entry.metadata + ): + store = entry.metadata.get("store", serialise) + document[entry.name] = store(getattr(self, entry.name)) return document @classmethod @@ -1085,54 +1115,24 @@ def from_json( held: dict[str, np.ndarray] = {} document = cast("dict[str, Any]", _resolved(document, arrays or {}, held)) - # Another build of this schema may lack a top-level entry. - stored = cast( - "Callable[..., Any]", functools.partial(_stored_entry, document, path=()) - ) - try: - noise = stored("noise") - resolution = stored("noise_resolution") - # Absent from a record written before the defaults were recorded. - defaults = stored("defaults") - git = stored("git") - backend = stored("backend") - metadata = cls( - grid=_rebuild(GridRecord, document["grid"], ("grid",)), - modes=tuple( - _rebuild(ModeRecord, one, ("modes", index)) - for index, one in enumerate(document["modes"]) - ), - simulation_kind=document["simulation_kind"], - configuration=document["configuration"], - stepper=StepperRecord.from_record(document["stepper"]), - noise=None - if noise is None - else _rebuild(NoiseRecord, noise, ("noise",)), - backend=None if backend is None else _rebuild(BackendRecord, backend), - versions=cast("Mapping[str, str]", stored("versions", default={})), - git=None if git is None else _rebuild(GitProvenance, git), - created=str(stored("created", default="")), - schema_version=document["schema_version"], - spectral_convention=str( - stored("spectral_convention", default=SPECTRAL_CONVENTION) - ), - field_layout=str(stored("field_layout", default=FIELD_LAYOUT)), - spatial=stored("spatial"), - modal=stored("modal"), - materials=MaterialManifest.from_dict(stored("materials")), - initial_z=stored("initial_z", default=0.0), - tdse=stored("tdse"), - quadrature=stored("quadrature"), - reference=stored("reference"), - transverse=stored("transverse"), - noise_resolution=None - if resolution is None - else _rebuild(NoiseResolution, resolution), - defaults=None - if defaults is None - else _rebuild(DefaultsResolution, defaults), - ) + values = {} + for entry in dataclasses.fields(cls): + if not entry.init: + continue + if entry.name in document: + value = document[entry.name] + elif ( + entry.default is dataclasses.MISSING + and entry.default_factory is dataclasses.MISSING + ): + raise KeyError(entry.name) + else: + # Another build of this schema may lack a top-level entry. + value = entry.metadata.get("absent", entry.default) + read = entry.metadata.get("read") + values[entry.name] = value if read is None else read(value) + metadata = cls(**values) except (KeyError, TypeError, MaterialError) as error: msg = f"stored metadata is missing or malformed: {error}" raise OutputError(msg) from error @@ -1143,6 +1143,17 @@ def from_json( return metadata +_HASHED_ENTRIES = ( + *( + entry.name + for entry in dataclasses.fields(RunMetadata) + if entry.metadata.get("hashed") + ), + "power_convention", +) +"""The entries of :meth:`RunMetadata.hashed`.""" + + def _example_metadata() -> RunMetadata: """Return a minimal record, so the examples above stay one line each. From 48a753b8771a4c43a051a2782e3e8a906a573c36 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 00:59:41 +0000 Subject: [PATCH 08/42] Keep one process_stamp, in solprop._process The Queue executor and the front-end service each had their own; the service's also covered Windows. The service's implementation moves into _process, which imports nothing from solprop and sits below both, and the executor's copy goes. Its Linux read is _proc_stamp, which treats a process that vanishes during the read as having no stamp and raises on anything else. service.record re-exports the name for its own users and the front end. On this Mac the old executor, the old service and the new function give identical stamps for this process, a live child and a missing pid; the Linux branch reads the same /proc field as both did. Co-Authored-By: Claude Opus 5.5 --- src/solprop/_process.py | 111 +++++++++++++++++++ src/solprop/driver/executors/_queue_state.py | 19 +--- src/solprop/service/record.py | 73 +----------- tests/driver/test_scan_owner.py | 34 ------ tests/test_process.py | 62 +++++++++++ 5 files changed, 175 insertions(+), 124 deletions(-) diff --git a/src/solprop/_process.py b/src/solprop/_process.py index 7478c03c..e64c42ce 100644 --- a/src/solprop/_process.py +++ b/src/solprop/_process.py @@ -12,6 +12,11 @@ gave a pool sized this way one thread). The affinity is therefore read once, when ``solprop`` is imported, before any of its runtimes start, and the later answer is believed only when it is not that collapse. + +It also says when a process started (:func:`process_stamp`), which tells a process +from a later one given its number: the scan queue's owners and the front end's runs +both need it, and this module, which imports nothing from the rest of the package, +sits below both. """ import contextlib @@ -659,3 +664,109 @@ def oversubscribing() -> bool: msg = f"SOLPROP_OVERSUBSCRIBE must be 0 or 1, and is {value!r}" raise ValueError(msg) return value == "1" + + +def process_stamp(pid: int) -> str | None: + """Return when a process started, as its operating system states it. + + A process number is reused once its process has gone; the start time tells a + run's own process from a later one with its number. + + Parameters + ---------- + pid : int + The process. + + Returns + ------- + str or None + Its start, in the system's own terms; ``None`` where the system does not say + or the process does not exist. + """ + if sys.platform == "linux": + return _proc_stamp(pid) + if sys.platform == "win32": # pragma: no cover - Windows only + return _windows_stamp(pid) + return _ps_stamp(pid) # pragma: no cover - macOS and other POSIX: not the lanes + + +def _proc_stamp(pid: int) -> str | None: + """Return a process's start as Linux's ``/proc`` gives it, in clock ticks. + + Parameters + ---------- + pid : int + The process. + + Returns + ------- + str or None + Field 22 of ``/proc//stat``; ``None`` when the process does not exist, + including when it exits while the file is read. + """ + try: + stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + except FileNotFoundError, ProcessLookupError: + return None + # The command name, field 2, is in parentheses and may itself hold them, so the + # fields are counted from the last closing one. + return stat.rsplit(")", 1)[1].split()[19] + + +def _ps_stamp(pid: int) -> str | None: # pragma: no cover - not Linux, see above + """Return a process's start as ``ps`` gives it, off Linux. + + Parameters + ---------- + pid : int + The process. + + Returns + ------- + str or None + Its start; ``None`` when it does not exist. + """ + import subprocess # noqa: PLC0415 - only off Linux + + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "lstart="], + capture_output=True, + text=True, + check=False, + ) + return result.stdout.strip() or None + + +def _windows_stamp(pid: int) -> str | None: # pragma: no cover - Windows only + """Return a Windows process's creation time, or ``None`` when it has gone. + + Parameters + ---------- + pid : int + The process. + + Returns + ------- + str or None + Its creation time in 100 ns units, as text. + """ + import ctypes # noqa: PLC0415 - Windows only + from ctypes import wintypes # noqa: PLC0415 - Windows only + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION + if not handle: + return None + try: + code = wintypes.DWORD() + if not kernel.GetExitCodeProcess(handle, ctypes.byref(code)): + return None + if code.value != 259: # STILL_ACTIVE + return None + times = [wintypes.FILETIME() for _ in range(4)] + if not kernel.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)): + return None + created = times[0] + return str((created.dwHighDateTime << 32) | created.dwLowDateTime) + finally: + kernel.CloseHandle(handle) diff --git a/src/solprop/driver/executors/_queue_state.py b/src/solprop/driver/executors/_queue_state.py index e6cb2cbd..07ec5ab3 100644 --- a/src/solprop/driver/executors/_queue_state.py +++ b/src/solprop/driver/executors/_queue_state.py @@ -7,7 +7,6 @@ import json import os import socket -import subprocess import tempfile import time import uuid @@ -15,6 +14,7 @@ from contextlib import contextmanager from pathlib import Path +from solprop._process import process_stamp from solprop.exceptions import DriverError from solprop.output._file_lock import lock, unlock from solprop.output.scan import _json @@ -64,23 +64,6 @@ def read_json(path: Path) -> dict: return value -def process_stamp(pid: int) -> str | None: - """Read process creation identity where the operating system exposes it. - - - Returns: process creation identity, or None if unavailable. - """ - proc = Path(f"/proc/{pid}/stat") - if proc.exists(): - return proc.read_text(encoding="utf-8").rsplit(")", 1)[1].split()[19] - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "lstart="], - capture_output=True, - text=True, - check=False, - ) - return result.stdout.strip() or None - - def owner() -> dict: """Identify this controller independently of each point's ownership token. diff --git a/src/solprop/service/record.py b/src/solprop/service/record.py index 80584f0c..b52ac064 100644 --- a/src/solprop/service/record.py +++ b/src/solprop/service/record.py @@ -30,7 +30,6 @@ import os import queue import socket -import sys import tempfile import threading import time @@ -39,6 +38,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Final +from solprop._process import process_stamp from solprop.service.frames import encode, split_frames from solprop.service.messages import TERMINAL, Event, ServiceError @@ -146,77 +146,6 @@ def _read_json(path: Path) -> dict[str, Any] | None: return value if isinstance(value, dict) else None -def process_stamp(pid: int) -> str | None: - """Return when a process started, as its operating system states it. - - A process number is reused once its process has gone; the start time tells a - run's own process from a later one with its number. - - Parameters - ---------- - pid : int - The process. - - Returns - ------- - str or None - Its start, in the system's own terms; ``None`` where the system does not say - or the process does not exist. - """ - if sys.platform == "linux": - try: - stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") - except OSError: - return None - return stat.rsplit(")", 1)[1].split()[19] - if sys.platform == "win32": # pragma: no cover - Windows only - return _windows_stamp(pid) - return _ps_stamp(pid) # pragma: no cover - macOS and other POSIX: not the lanes - - -def _ps_stamp(pid: int) -> str | None: # pragma: no cover - not Linux, see above - """Return a process's start as ``ps`` gives it, off Linux. - - - Returns: its start; ``None`` when it does not exist. - """ - import subprocess # noqa: PLC0415 - only off Linux - - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "lstart="], - capture_output=True, - text=True, - check=False, - ) - return result.stdout.strip() or None - - -def _windows_stamp(pid: int) -> str | None: # pragma: no cover - Windows only - """Return a Windows process's creation time, or ``None`` when it has gone. - - - Returns: its creation time in 100 ns units, as text. - """ - import ctypes # noqa: PLC0415 - Windows only - from ctypes import wintypes # noqa: PLC0415 - Windows only - - kernel = ctypes.WinDLL("kernel32", use_last_error=True) - handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION - if not handle: - return None - try: - code = wintypes.DWORD() - if not kernel.GetExitCodeProcess(handle, ctypes.byref(code)): - return None - if code.value != 259: # STILL_ACTIVE - return None - times = [wintypes.FILETIME() for _ in range(4)] - if not kernel.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)): - return None - created = times[0] - return str((created.dwHighDateTime << 32) | created.dwLowDateTime) - finally: - kernel.CloseHandle(handle) - - def alive(pid: int, stamp: str | None) -> bool: """Return whether a process of this machine still runs, and is the one meant. diff --git a/tests/driver/test_scan_owner.py b/tests/driver/test_scan_owner.py index 40d2a2b1..711b69b3 100644 --- a/tests/driver/test_scan_owner.py +++ b/tests/driver/test_scan_owner.py @@ -5,7 +5,6 @@ """Verify local-owner identity checks without relying on a running scheduler.""" import socket -import subprocess import sys import pytest @@ -26,39 +25,6 @@ def test_queue_record_requires_a_json_object(tmp_path): state.read_json(path) -def test_linux_process_stamp_handles_parentheses_in_process_name(tmp_path, monkeypatch): - path = tmp_path / "stat" - path.write_text( - "123 (command (with) brackets) " + " ".join(str(i) for i in range(30)) - ) - monkeypatch.setattr(state, "Path", lambda name: path) - assert state.process_stamp(123) == "19" - - -@pytest.mark.parametrize( - "printed,stamp", - [("Tue Sep 23 13:44:28 2026\n", "Tue Sep 23 13:44:28 2026"), ("", None)], -) -def test_process_stamp_asks_ps_where_there_is_no_proc( - tmp_path, monkeypatch, printed, stamp -): - """Without `/proc` (macOS) the stamp is `ps`'s start time, or None if it has none. - - `/proc` is taken away on every platform, so Linux exercises the branch only - macOS takes in production. - """ - monkeypatch.setattr(state, "Path", lambda name: tmp_path / "absent") - calls = [] - - def ps(arguments, **options): - calls.append(arguments) - return subprocess.CompletedProcess(arguments, 0, stdout=printed, stderr="") - - monkeypatch.setattr(state.subprocess, "run", ps) - assert state.process_stamp(123) == stamp - assert calls == [["ps", "-p", "123", "-o", "lstart="]] - - def test_owner_with_invalid_pid_can_be_reclaimed(): assert not state.live_owner( {"owner": {"host": socket.gethostname(), "pid": 0}}, 60.0 diff --git a/tests/test_process.py b/tests/test_process.py index abcfa7d7..91544f84 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -6,6 +6,7 @@ import importlib.util import os +import subprocess import sys import warnings from pathlib import Path @@ -536,3 +537,64 @@ def test_a_spawn_environment_is_the_child_s_and_the_parent_s_comes_back(monkeypa with pytest.raises(RuntimeError), _process.spawn_environment({"ADDED": "child"}): raise RuntimeError assert dict(os.environ) == before + + +# --------------------------------------------------------------------- process start + + +def test_a_linux_stamp_counts_fields_past_a_name_with_parentheses( + tmp_path, monkeypatch +): + path = tmp_path / "stat" + path.write_text( + "123 (command (with) brackets) " + " ".join(str(i) for i in range(30)), + encoding="utf-8", + ) + monkeypatch.setattr(_process, "Path", lambda name: path) + assert _process._proc_stamp(123) == "19" + + +@pytest.mark.parametrize("error", [FileNotFoundError, ProcessLookupError]) +def test_a_process_that_exits_while_its_stamp_is_read_has_none(monkeypatch, error): + """The process is gone by the time `/proc//stat` is read.""" + + class Vanishing: + def read_text(self, encoding): + raise error(2, "No such file or directory") + + monkeypatch.setattr(_process, "Path", lambda name: Vanishing()) + assert _process._proc_stamp(123) is None + + +def test_a_stamp_read_that_fails_otherwise_raises(monkeypatch): + # Only a vanished process means no identity; a refused read is an error. + class Refused: + def read_text(self, encoding): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(_process, "Path", lambda name: Refused()) + with pytest.raises(PermissionError): + _process._proc_stamp(123) + + +@pytest.mark.parametrize( + ("printed", "stamp"), + [("Tue Sep 23 13:44:28 2026\n", "Tue Sep 23 13:44:28 2026"), ("", None)], +) +def test_a_stamp_without_proc_is_ps_s_start_time(monkeypatch, printed, stamp): + """Off Linux (macOS) the stamp is `ps`'s start time, or None if it has none.""" + calls = [] + + def ps(arguments, **options): + calls.append(arguments) + return subprocess.CompletedProcess(arguments, 0, stdout=printed, stderr="") + + monkeypatch.setattr(subprocess, "run", ps) + assert _process._ps_stamp(123) == stamp + assert calls == [["ps", "-p", "123", "-o", "lstart="]] + + +@pytest.mark.skipif(sys.platform == "win32", reason="the stamp of a POSIX process") +def test_this_process_has_a_stamp_and_a_missing_one_has_none(): + assert _process.process_stamp(os.getpid()) + assert _process.process_stamp(2**22 + 7) is None From a44bf3724f53f1116a9091f7865957797f1ea9e4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:57:19 +0000 Subject: [PATCH 09/42] Split driver/simulation.py by concern A pure move: Simulation stays in simulation.py; its settings and their admission go to settings.py, its input, launch and noise to inputs.py, its run record to metadata.py, resume to resume.py, the many-input runner to runner.py, the TDSE run glue to tdse_run.py, and the keyword routes to routes/ (gnlse, hollow, general, spatial, with the material selection and keyword attribution they share). The methods _from_spatial, _from_general and _from_hollow, the resume methods and the TDSE run methods become module functions taking the simulation (or its class). Imports, test monkeypatches and documentation references follow. Co-Authored-By: Claude Opus 5.5 --- docs/dev/architecture.md | 2 +- docs/dev/figures/build.py | 10 + docs/dev/figures/layers.svg | 158 +- docs/guide/numerics/solver.md | 2 +- docs/guide/performance/resources.md | 2 +- docs/guide/setup/running.md | 8 +- src/solprop/backends/host/driver.py | 4 +- src/solprop/driver/__init__.py | 10 +- src/solprop/driver/_scan_run.py | 3 +- src/solprop/driver/defaults.py | 4 +- src/solprop/driver/inputs.py | 995 ++ src/solprop/driver/interface.py | 26 +- src/solprop/driver/metadata.py | 449 + src/solprop/driver/resume.py | 422 + src/solprop/driver/routes/__init__.py | 13 + src/solprop/driver/routes/general.py | 493 + src/solprop/driver/routes/gnlse.py | 227 + src/solprop/driver/routes/hollow.py | 1714 ++ src/solprop/driver/routes/keywords.py | 123 + src/solprop/driver/routes/materials.py | 150 + src/solprop/driver/routes/spatial.py | 349 + src/solprop/driver/run.py | 6 +- src/solprop/driver/runner.py | 674 + src/solprop/driver/settings.py | 1001 ++ src/solprop/driver/simulation.py | 13007 ++++------------ src/solprop/driver/summary.py | 2 +- src/solprop/driver/tdse_run.py | 602 + src/solprop/recipe/metadata.py | 2 +- src/solprop/recipe/provenance.py | 2 +- src/solprop/recipe/registry.py | 2 +- src/solprop/sol.py | 3 +- tests/backends/test_compiled_multistep.py | 3 +- tests/backends/test_dense_output.py | 3 +- tests/backends/test_flux_gradients.py | 3 +- tests/backends/test_flux_paths.py | 3 +- tests/backends/test_held_plasma_contract.py | 3 +- tests/backends/test_multistep_march.py | 3 +- tests/backends/test_on_axis_statistics.py | 2 +- tests/conftest.py | 4 +- tests/driver/test_absorber_keywords.py | 2 +- tests/driver/test_capillary_components.py | 2 +- tests/driver/test_coherent_capillary.py | 2 +- tests/driver/test_general_modal.py | 11 +- tests/driver/test_initial_z_and_checkpoint.py | 31 +- tests/driver/test_interface_defaults.py | 16 +- tests/driver/test_low_storage_settings.py | 2 +- tests/driver/test_material_manifest.py | 2 +- tests/driver/test_noise_default.py | 3 +- tests/driver/test_optmat_rates.py | 2 +- tests/driver/test_parameter_paths.py | 2 +- tests/driver/test_plane_bases.py | 6 +- tests/driver/test_prop_capillary.py | 5 +- tests/driver/test_prop_gnlse.py | 3 +- tests/driver/test_resume_gate.py | 2 +- tests/driver/test_runner.py | 2 +- tests/driver/test_scan_kept_backend.py | 5 +- tests/driver/test_scan_rdw.py | 2 +- tests/driver/test_simulation.py | 3 +- tests/driver/test_snapshots.py | 4 +- tests/driver/test_specified_paths.py | 2 +- tests/driver/test_step_planes.py | 2 +- tests/driver/test_stepindex_gnlse.py | 2 +- tests/driver/test_summary.py | 2 +- tests/driver/test_tdse_entry.py | 8 +- tests/driver/test_tdse_hacc_run.py | 4 +- tests/driver/test_temperature_paths.py | 2 +- tests/driver/test_waveguide.py | 6 +- tests/modes/test_planar_grid.py | 2 +- tests/output/test_snapshot_failures.py | 4 +- tests/recipe/test_metadata.py | 2 +- tests/recipe/test_provenance.py | 2 +- tests/sessions/test_destination_export.py | 2 +- tests/sessions/test_native_session_monitor.py | 2 +- tests/sessions/test_service_loop.py | 2 +- tests/test_benchmark_cases.py | 2 +- tests/test_collar_length.py | 4 +- tests/test_grid.py | 2 +- tests/test_sep3_defaults.py | 3 +- tools/size_budget.toml | 1 - 79 files changed, 10616 insertions(+), 10036 deletions(-) create mode 100644 src/solprop/driver/inputs.py create mode 100644 src/solprop/driver/metadata.py create mode 100644 src/solprop/driver/resume.py create mode 100644 src/solprop/driver/routes/__init__.py create mode 100644 src/solprop/driver/routes/general.py create mode 100644 src/solprop/driver/routes/gnlse.py create mode 100644 src/solprop/driver/routes/hollow.py create mode 100644 src/solprop/driver/routes/keywords.py create mode 100644 src/solprop/driver/routes/materials.py create mode 100644 src/solprop/driver/routes/spatial.py create mode 100644 src/solprop/driver/runner.py create mode 100644 src/solprop/driver/settings.py create mode 100644 src/solprop/driver/tdse_run.py diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index b265d456..720bb10c 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -218,7 +218,7 @@ against `src/solprop` by `tests/test_structure_figures.py`. | `backends/numpy_ref/` | Reference execution, its Adams kernels and tables (`multistep.py`) and its on-axis scan (`populations.py`) | B | | `backends/host/` | The reference's own step driver, Adams and low-storage marches, fixed-schedule group, run session and group session ([SEP 47](seps/archive/sep-0047-run-ownership.md) phase 2); the parts the Python sessions compose are in `backends/session_core.py` | B | | `stepper/`, `log.py`, `stats.py` | Tableaux, controllers, schedules, progress reporters and statistics specifications | D | -| `driver/`, `driver/executors/`, `scan.py` | Simulation and convenience interfaces, scan/batch orchestration, Local/Pool/Queue/Slurm execution and scan CLI | D | +| `driver/`, `driver/routes/`, `driver/executors/`, `scan.py` | The `Simulation` object (`simulation.py`) with its settings and their admission (`settings.py`), its input, launch, noise and peak field (`inputs.py`), its run record (`metadata.py`), resume (`resume.py`), many-input runner (`runner.py`) and TDSE run glue (`tdse_run.py`); the keyword routes behind its constructors, one module per family (`routes/`: GNLSE, hollow guides, general modal guides, spatial grids, and the material selection and keyword attribution they share); the convenience interfaces, scan/batch orchestration, Local/Pool/Queue/Slurm execution and scan CLI | D | | `calibration/` | The native backend's machine calibration ([SEP 46](seps/archive/sep-0046-machine-calibration.md)): reading, showing, exporting, importing and clearing profiles in the per-user store, through solprop-native's standard-library modules loaded by path so that the native runtime never starts (`calibration/_native.py`), the measuring controller, its child processes, the synthetic problems they time, the operation-grain candidates and the selection rule (`calibration/controller.py`, `child.py`, `problems.py`, `grain.py`, `selection.py`), the refinement of one simulation (`calibration/refine.py`, stage 2, which `Simulation.calibrate` runs), and the `solprop calibrate` command (`calibration/command.py`) | D | | `output/` | Memory and HDF5 outputs, provenance/resume, scan storage and artifacts | D | | `processing/`, `plotting/`, `sol.py` | Field, spectrum, time, coherence, compression, mixture and scan analysis; a saved plane read back as a field plane or a fluence map (`processing/planes.py`), and a field plane's file form (`processing/plane_file.py`); propagation/statistics plots and spectrograms; the interactive session namespace, the one module whose import has side effects | X | diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 216dead8..7ddbf936 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -792,10 +792,20 @@ class Layer(NamedTuple): ), ), Module("driver/executors/", ("local", "pool", "queue", "slurm", "batch")), + Module( + "driver/routes/", + ("gnlse", "hollow", "general", "spatial", "materials", "keywords"), + ), Module( "driver/", ( "simulation", + "settings", + "inputs", + "metadata", + "resume", + "runner", + "tdse_run", "run", "summary", "interface", diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index c722e22a..b210c76b 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -1,6 +1,6 @@ + width="1200" height="1931.0" viewBox="0 0 1200 1931.0"> - + The layers of solprop, and every module in each Bands and colours from architecture.md §3 and decisions.md ADR-001; modules from src/solprop. Arrows point from the layer that imports to the layer it imports. @@ -195,13 +195,13 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: backend · batch · passes admission · envelope_plasma general_modal · tdse - - -D -driver -may import -P, T -B through the seam, h5py + + +D +driver +may import +P, T +B through the seam, h5py log.py @@ -227,56 +227,62 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: driver/executors/ local · pool · queue · slurm batch - -driver/ -simulation · run · summary -interface · scaffold · mixtures -material_output · tdse -tdse_consistency · scan · batch -defaults · estimate · plane_view -service - -recipe/ -core · document · errors · export -metadata · paths · pins · preview -provenance · quantity · registry - -cli.py - - -X -processing / plotting -may import -numpy, scipy -matplotlib, D (outputs) -T (table identities) - -processing/ -beams · axes · fields · spectra -time · coherence · compression -mixtures · modal · planes -plane_file · scans - -plotting/ -propagation · statistics -spectrograms · style · geometry - -sol.py - -capabilities.py - -service/ -agent · beam · beams · client -design · explore · figures · frames -handlers · install · job · kernel -launch · machines · messages -postprocess · recipes · record -reduce · results · run · runs -scan_reductions · sheet · states -waveguide · worker - -processing/ -windows + +driver/routes/ +gnlse · hollow · general · spatial +materials · keywords + +driver/ +simulation · settings · inputs +metadata · resume · runner +tdse_run · run · summary +interface · scaffold · mixtures +material_output · tdse +tdse_consistency · scan · batch +defaults · estimate · plane_view +service + +recipe/ +core · document · errors · export +metadata · paths · pins · preview +provenance · quantity · registry + +cli.py + + +X +processing / plotting +may import +numpy, scipy +matplotlib, D (outputs) +T (table identities) + +processing/ +beams · axes · fields · spectra +time · coherence · compression +mixtures · modal · planes +plane_file · scans + +plotting/ +propagation · statistics +spectrograms · style · geometry + +sol.py + +capabilities.py + +service/ +agent · beam · beams · client +design · explore · figures · frames +handlers · install · job · kernel +launch · machines · messages +postprocess · recipes · record +reduce · results · run · runs +scan_reductions · sheet · states +waveguide · worker + +processing/ +windows T → P physics objects @@ -286,28 +292,28 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: D → B backends.select, then the seam - - -X → D a saved run, through the Output protocol - + + +X → D a saved run, through the Output protocol + - -1 - + +1 + - -2 + +2 3 - + - -4 -1 Simulation calls translate.assemble -2 the driver builds the grid, the modes, the pulse and the responses -3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object -4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X -The package-wide modules carry no layer letter: __init__.py · exceptions.py + +4 +1 Simulation calls translate.assemble +2 the driver builds the grid, the modes, the pulse and the responses +3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object +4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X +The package-wide modules carry no layer letter: __init__.py · exceptions.py \ No newline at end of file diff --git a/docs/guide/numerics/solver.md b/docs/guide/numerics/solver.md index c132488c..fb96d175 100644 --- a/docs/guide/numerics/solver.md +++ b/docs/guide/numerics/solver.md @@ -44,7 +44,7 @@ or accuracy guarantee. Three integrators take the steps, and everything else on this page — the linear flow, the source definitions, caps and save-plane conventions — is used by all of them. `integrator=` on every entry point and on -{py:class}`~solprop.driver.simulation.SimulationSettings` chooses between `Multistep()`, +{py:class}`~solprop.driver.settings.SimulationSettings` chooses between `Multistep()`, the exponential Adams method ([the Adams integrator](adams.md)), `RungeKutta()`, the interaction-picture Dormand–Prince pair below, and `LowStorageRungeKutta()`, a fixed-schedule RK4; [choosing a stepper](choosing-a-stepper.md) describes each, gives diff --git a/docs/guide/performance/resources.md b/docs/guide/performance/resources.md index 36e518cc..81b8f628 100644 --- a/docs/guide/performance/resources.md +++ b/docs/guide/performance/resources.md @@ -85,7 +85,7 @@ planes (`n_save`) or a file (`output="run.h5"`), which writes each plane out, sh The step history grows with the steps taken. **The propagator table.** `propagator_mode` on -{py:class}`~solprop.driver.simulation.SimulationSettings` decides whether the stage +{py:class}`~solprop.driver.settings.SimulationSettings` decides whether the stage propagators are kept for the whole step (`"tabulate"`) or recomputed in each stage (`"recompute"`), which changes the memory held, not the method. Its default, `"auto"`, leaves the choice to the backend and records what was resolved in the diff --git a/docs/guide/setup/running.md b/docs/guide/setup/running.md index 21d33b15..7bbf9398 100644 --- a/docs/guide/setup/running.md +++ b/docs/guide/setup/running.md @@ -250,7 +250,7 @@ Gauss or Gauss–Kronrod rule integrates Kerr and Raman overlaps accurately when its order resolves the mode profiles, converges spectrally for a smooth ionisation rate, and costs the same on every step, where an adaptive cubature's cost depends on the field and its error estimate can be far too optimistic with a kinked rate. Its radial order `n_r` defaults to -{py:func}`~solprop.driver.simulation.default_radial_order` of the mode count -- 32 up to +{py:func}`~solprop.driver.routes.hollow.default_radial_order` of the mode count -- 32 up to four modes, eight nodes per mode above that. `radial_rule="gauss_kronrod"`, the default, makes the rule the Kronrod extension of the half-order Gauss rule, whose embedded Gauss subset costs nothing and gives the run an error estimate (the quadrature alarm, below). `n_theta` is @@ -340,7 +340,7 @@ runs default to no plasma. The span of the table comes from the input's own peak field, over the axis and the run's transverse nodes (the axis for an `HE_1m` launch, the nodes for a `TE_0m` or `TM_0m` one, whose field vanishes on the axis), unless `ionisation_field_range` gives one -- -{py:func}`~solprop.driver.simulation.default_field_range` explains the two factors. Above +{py:func}`~solprop.driver.inputs.default_field_range` explains the two factors. Above the upper limit the table holds its last value, so one set too low is a silent clip of the rate exactly where ionisation matters; below the lower one the rate is zero, which is where every rate in this package already is. @@ -411,7 +411,7 @@ memory = simulation.run() # into memory simulation.run("run.h5") # and again, into a file, without translating twice ``` -{py:class}`~solprop.driver.simulation.SimulationSettings` holds everything numerical: +{py:class}`~solprop.driver.settings.SimulationSettings` holds everything numerical: the controller and its tolerances, the save planes, the step limits, a fixed schedule, the tableau, the absorber and how it is applied, the transverse quadrature, the statistics and the backend. @@ -449,7 +449,7 @@ state are allocated and first touched, and the transforms are planned. A delay s sweep of the input runs the same translated problem for hundreds of inputs, and need pay that once. {py:meth}`~solprop.driver.simulation.Simulation.runner` translates (or reuses the cached translation) and constructs one backend; each -{py:meth}`~solprop.driver.simulation.SimulationRunner.run` then loads one input into it +{py:meth}`~solprop.driver.runner.SimulationRunner.run` then loads one input into it and runs the save-plane loop ([SEP 22](../../dev/seps/archive/sep-0022-memory-lean-spatial-execution.md) §5.2): diff --git a/src/solprop/backends/host/driver.py b/src/solprop/backends/host/driver.py index 6e824623..b84c774f 100644 --- a/src/solprop/backends/host/driver.py +++ b/src/solprop/backends/host/driver.py @@ -716,7 +716,7 @@ def prepare( ---------- problem : solprop.translate.problem.PropagationProblem The translated problem: this backend's, or one derived from it with - another input (:func:`solprop.driver.simulation.check_derived_problem`). + another input (:func:`solprop.driver.runner.check_derived_problem`). request : solprop.translate.run_request.RunRequest The run's request. @@ -1078,7 +1078,7 @@ def set_initial( (``docs/dev/stepper.md`` section 3). Everything else a march reads is restored too, so that a backend loaded again - after a run -- a :class:`~solprop.driver.simulation.SimulationRunner`'s, or + after a run -- a :class:`~solprop.driver.runner.SimulationRunner`'s, or after a failed run -- repeats a fresh backend's run (SEP 22 section 4.7): a dense observation's look-ahead, an Adams march's call ledger and last-rejection flag, a low-storage march's source-evaluation count and the boundary monitor, diff --git a/src/solprop/driver/__init__.py b/src/solprop/driver/__init__.py index ddabba67..fc5060f7 100644 --- a/src/solprop/driver/__init__.py +++ b/src/solprop/driver/__init__.py @@ -22,18 +22,16 @@ prop_radial, prop_waveguide, ) +from solprop.driver.metadata import metadata_for from solprop.driver.run import ( LogReporter, ProgressReporter, plane_from_observation, run_problem, ) -from solprop.driver.simulation import ( - Simulation, - SimulationRunner, - SimulationSettings, - metadata_for, -) +from solprop.driver.runner import SimulationRunner +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.driver.tdse import HaccPreset, hacc_basis, hacc_preset from solprop.driver.tdse_consistency import ConsistencyResult, check_consistency diff --git a/src/solprop/driver/_scan_run.py b/src/solprop/driver/_scan_run.py index 973f3e79..fe1665a6 100644 --- a/src/solprop/driver/_scan_run.py +++ b/src/solprop/driver/_scan_run.py @@ -33,7 +33,8 @@ from solprop.driver.executors.pool import PlaneScalars, peak_rss from solprop.driver.executors.queue import Queue, collect from solprop.driver.executors.slurm import Slurm -from solprop.driver.simulation import Simulation, SimulationRunner +from solprop.driver.runner import SimulationRunner +from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError, OutputError, StopRequested from solprop.fourier import spectrum_to_optics from solprop.log import ( diff --git a/src/solprop/driver/defaults.py b/src/solprop/driver/defaults.py index 0fb0d5fa..c13c4999 100644 --- a/src/solprop/driver/defaults.py +++ b/src/solprop/driver/defaults.py @@ -107,7 +107,7 @@ Set by SEP 37's convergence protocol on the benchmark suite's ``radial_filament_256`` and ``planar`` cases. It applies only where the caller's -:class:`~solprop.driver.simulation.SimulationSettings` gives no controller. +:class:`~solprop.driver.settings.SimulationSettings` gives no controller. """ SPATIAL_ATOL = 0.0 @@ -120,7 +120,7 @@ multistep method out for a large transverse state. On a self-focusing Kerr beam the spectrum stays within 0.002 dB of a tight reference even at 1e-3; 1e-4 cuts the field error by 2.5 times for a sixth more steps. Applies only where the caller's -:class:`~solprop.driver.simulation.SimulationSettings` gives no controller. +:class:`~solprop.driver.settings.SimulationSettings` gives no controller. """ FREE_ATOL = 0.0 diff --git a/src/solprop/driver/inputs.py b/src/solprop/driver/inputs.py new file mode 100644 index 00000000..f38e7b75 --- /dev/null +++ b/src/solprop/driver/inputs.py @@ -0,0 +1,995 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A run's input: its pulse, its launch, its shot noise and its peak field. + +The keyword routes shape their pulses and draw their noise here; a derived run's input +is checked and installed here; and the input's peak field sets the range of each +optmat ionisation rate a run carries (SEP 3). +""" + +import dataclasses +import functools +import logging +import math +from collections.abc import Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Final, cast + +import numpy as np +from jaxtyping import Complex + +from solprop.coupling import ( + CouplingLaunch, + CouplingModel, + FieldPlane, + PlaneGeometry, + couple, +) +from solprop.driver._launch import launch_pulse +from solprop.driver.defaults import NoiseModel +from solprop.driver.routes.keywords import _PULSE_KEYWORDS, _about +from solprop.driver.settings import SimulationSettings +from solprop.exceptions import CouplingError, DriverError +from solprop.fourier import power_amplitude +from solprop.grid import FreeGrid, RadialGrid, TimeGrid +from solprop.modes.collection import ModeCollection +from solprop.modes.guide import ModalGuide +from solprop.modes.protocol import Mode +from solprop.modes.quadrature import TransverseQuadrature, mode_matrix, on_axis_rule +from solprop.modes.slab import PlanarGrid +from solprop.noise import ( + NoiseRealisation, + input_noise, + modified_noise, + spatial_noise, + transformed_noise, +) +from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse +from solprop.nonlinear.optmat_rates import ( + OptmatRate, + bind_reference_field, +) +from solprop.nonlinear.responses import PlasmaResponse +from solprop.output.schema import _recorded_inline +from solprop.pulses import ( + GaussianPulse, + PlanarPulse, + Pulse, + PulseSum, + SechPulse, + SpatialPulse, + SpatialPulseSum, +) +from solprop.spatial import SpatialFlux, SpatialMedium, peak_field +from solprop.stats import analytic_field +from solprop.translate.linop import power_weight_at +from solprop.translate.masks import noise_spectrum +from solprop.translate.problem import ( + PropagationProblem, + _held_block, + _held_state, + _hold, + _state, +) + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = ["ModalLaunch", "default_field_range"] + +logger = logging.getLogger(__name__) + +_FIELD_RANGE_BELOW = 1e-3 +"""Lower end of the default ionisation table, as a factor on the input's peak field.""" + +_FIELD_RANGE_ABOVE = 30.0 +"""Upper end of the default ionisation table, as a factor on the input's peak field.""" + +ModalLaunch = ( + Mapping[str | int, float | Mapping[str, float]] + | Sequence[Mapping[str | int, float | Mapping[str, float]]] +) +"""What a modal constructor's ``launch`` keyword takes (SEP 41, S5). + +A mapping from a mode's display label or index to its share, or to +``{"share": s, "phase": phi}`` with the phase in radians; or a list of such mappings, +one for each input pulse (``docs/guide/setup/pulses.md``). +""" + + +def _realisation(problem: PropagationProblem) -> NoiseRealisation | None: + """Return a derived problem's noise as the backend loads it, or ``None``. + + - Returns: its realisation, or ``None`` without noise. + """ + initial = problem.initial + if initial.noise0 is None or initial.noise_model == "none": + return None + return NoiseRealisation( + spectrum=initial.noise0, seed=None, model=initial.noise_model + ) + + +def _check_initial_position( + simulation: "Simulation", settings: SimulationSettings +) -> None: + """Validate an absolute launch before translation can normalize its field. + + - ``simulation``: declared optical input, grid and absolute launch position in m. + """ + z = simulation.initial_z + if ( + not np.isfinite(z) + or z < 0 + or z >= simulation.grid.length + or np.any(np.asarray(settings.save_planes) <= z) + ): + raise DriverError( + "initial_z must be finite, nonnegative and before every save plane" + ) + if z == 0: + return + if ( + not isinstance(simulation.initial, np.ndarray) + or simulation.spatial_grid is not None + ): + raise DriverError("an interior launch requires an explicit modal spectrum") + if ( + simulation.noise is not None and simulation.noise.model == "modified" + ) or settings.schedule is not None: + raise DriverError( + "an interior launch refuses the modified noise model and a fixed schedule; " + "input noise takes its draw at the launch (SEP 37)" + ) + + +def _replaced_launch( + original: PropagationProblem, + field0: Complex[np.ndarray, "n_modes n_pol n_omega"], + launch_z: float, +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a replaced input as the derived problem's launch, without a further copy. + + On a free-space or spatial route the launch is a view of the derived simulation's + own copy; on a modal route it is the power conversion of that copy, a second array + beside it. + + - ``field0``: the replacement input, in true sqrt(W) on a modal route. + - ``launch_z``: where it is launched, m. + """ + if original.transform.geometry is not None: + return _state(field0, original.shape[-1]) + # The conversion is a new array, held as it is rather than copied again. + converted = power_amplitude( + field0, power_weight_at(original, launch_z), inverse=True + ) + return _hold(np.asarray(converted, dtype=np.complex128)) + + +def _check_input_identity( + identity: str | None, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray | CouplingLaunch, +) -> None: + """Refuse a stated identity that is empty, or of anything but an array input.""" + if identity is None: + return + if not identity: + raise DriverError("input_identity must say what the input is: it is empty") + if not isinstance(initial, np.ndarray): + msg = ( + "input_identity states an array input's identity; " + f"a {type(initial).__name__} is recorded as itself" + ) + raise DriverError(msg) + + +def _given_input( + given: np.ndarray, held: Complex[np.ndarray, "n_modes n_pol n_omega"] +) -> np.ndarray: + """Return an array input as the caller gave it, for the record: a read-only view. + + The record keeps the caller's dtype and values, so that a file written from such + an input before the simulation copied it still resumes; a view of a read-only owner + cannot be made writeable, so the record and the run cannot be changed through it. + + - Returns: a read-only view of `held` when `given` is complex128, otherwise of a + held copy of `given` in its own dtype; :class:`~solprop.translate.problem.Initial` + takes either as held. + """ + # The whole dtype, byte order included: the record serialises its exact string. + source = held if given.dtype == held.dtype else _held_block(given, given.dtype) + return _hold(source.view()) + + +def _check_planar_input(shape: tuple[int, ...], n_columns: int, n_omega: int) -> None: + """Refuse a planar input whose shape is not ``(n_p*n_y, 1, n_omega)``. + + - ``shape``: the input's shape. + """ + if shape != (n_columns, 1, n_omega): + msg = ( + "a planar input has shape (n_p*n_y, 1, n_omega) = " + f"{(n_columns, 1, n_omega)}, got {shape}" + ) + raise DriverError(msg) + + +def _check_spatial_input(shape: tuple[int, ...], n_columns: int, n_omega: int) -> None: + """Refuse a spatial input whose shape is not ``(n_columns, n_pol, n_omega)``. + + - ``shape``: the input's shape. + """ + if len(shape) != 3 or shape[0] != n_columns or shape[2] != n_omega: + msg = "spatial input must have shape (n_columns, n_pol, n_omega)" + raise DriverError(msg) + + +def _built( + initial: Pulse | np.ndarray, grid: TimeGrid +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return the initial spectrum, building a pulse against the grid when given one. + + - Returns: the spectrum, complex128, shape ``(n_modes, n_pol, n_omega)``, read-only + and the simulation's own: a copy of an array (of its repeated block for a + broadcast), or a solprop pulse's new build. + """ + if isinstance(initial, np.ndarray): + # Copied now, so that changing the caller's array later changes nothing, as + # the problem holds it: a broadcast copies its block alone, and an array a + # simulation already holds (its own, passed back) is taken as it is. + return _held_state(initial) + # A runtime-checkable protocol is satisfied by the presence of `build`, not by its + # being callable, so the guard below is still the one that names the mistake. + build = getattr(initial, "build", None) + if not callable(build): + msg = ( + f"the input must be a pulse with a build(grid) method or a built spectrum, " + f"got {type(initial).__name__}" + ) + raise DriverError(msg) + return _built_state(initial, build(grid)) + + +def _built_state( + pulse: object, built: object +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a pulse's built spectrum as the problem holds it, copied only if foreign. + + A build of solprop's own pulse classes is a new array solprop made, so it is held + as it is; any other pulse's build may be an array the pulse keeps, and is copied + (:class:`~solprop.translate.problem.Initial`). + + - Returns: the spectrum, complex128, read-only and held. + """ + spectrum = np.asarray(built) + build = getattr(type(pulse), "build", None) + own = getattr(build, "__module__", None) == _PULSE_MODULE + if own and spectrum.dtype == np.complex128: + return _hold(spectrum) + return _held_state(spectrum) + + +_PULSE_MODULE: Final = SpatialPulse.__module__ +"""The module of solprop's pulse classes, whose ``build`` always makes a new array.""" + + +def _shaped_pulse( + shape: str, + *, + wavelength: float, + fwhm: float | None, + width: float | None, + energy: float | None, + peak_power: float | None, + phases: Sequence[float] | np.ndarray, + pulses: Sequence[Pulse] | None = None, +) -> GaussianPulse | SechPulse | PulseSum: + """Return the shaped input pulse a keyword interface starts from. + + - ``wavelength``: centre wavelength, m, and the reference of the spectral phase. + - ``fwhm``: intensity full width at half maximum, s. + - ``width``: natural width of a ``sech**2``, s. + - ``energy``: pulse energy, J. + - ``peak_power``: peak power, W. + - ``phases``: Taylor coefficients of the optics-convention spectral phase. + """ + if pulses is not None: + given = [ + name + for name, value in ( + ("fwhm", fwhm), + ("width", width), + ("energy", energy), + ("peak_power", peak_power), + ) + if value is not None + ] + if len(phases): + given.append("phases") + if shape != "gauss": + given.append("shape") + if given: + msg = ( + f"pulses= describes every input pulse itself, and this call also sets " + f"{', '.join(given)}: give each pulse its own duration, energy and " + "phase, and leave the single-pulse keywords unset" + ) + raise DriverError( + msg, parameter=("pulses", *given), rule="pulses-or-pulse-keywords" + ) + with _about("pulses"): + return PulseSum(tuple(pulses)) + if shape == "sech": + # The pulse names its own arguments, which are the keywords but for phase. + with _about(renamed=_PULSE_KEYWORDS): + return SechPulse( + wavelength=wavelength, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phase=phases, + ) + if shape != "gauss": + msg = f"shape must be 'gauss' or 'sech', got {shape!r}" + raise DriverError(msg, parameter="shape") + if width is not None or fwhm is None: + msg = ( + "a Gaussian input is specified by its intensity fwhm; give fwhm= rather " + "than width=, which is the natural width of a sech**2 pulse" + ) + raise DriverError( + msg, + parameter="fwhm" if width is None else ("width", "shape"), + rule="gauss-fwhm", + ) + with _about(renamed=_PULSE_KEYWORDS): + return GaussianPulse( + wavelength=wavelength, + fwhm=fwhm, + energy=energy, + peak_power=peak_power, + phase=phases, + ) + + +def _shot_noise( + noise: str | None, grid: TimeGrid, seed: int | None, *, n_modes: int = 1 +) -> NoiseRealisation | None: + """Draw the shot-noise realisation a run carries. + + The model is a plain string rather than :data:`NoiseModel` for the reason + :func:`solprop.fourier._check_kind` gives: this is the run-time guard for callers + that ignore the annotation, and the test session's ``beartype`` hook would otherwise + reject the bad value before the check below is reached. + + - ``noise``: which model to use, or ``None`` for a noiseless run. + - ``seed``: entropy of the generator; ``None`` draws from the operating system, and + the entropy it drew is recorded on the realisation either way. + """ + if noise is None: + return None + if noise not in ("input", "modified"): + msg = f"noise must be 'default', None, 'input' or 'modified', got {noise!r}" + raise DriverError(msg, parameter="noise") + generator = np.random.default_rng(seed) + draw = input_noise if noise == "input" else modified_noise + return draw(grid, generator, n_modes=n_modes) + + +def _general_noise( + noise: str | None, + grid: TimeGrid, + seed: int | None, + guide: ModalGuide, +) -> NoiseRealisation | None: + """Draw a general modal run's shot noise, in the basis it is defined in. + + The shot-noise draw (``docs/guide/media/noise.md``) is one photon per channel per + bin, which is an energy-normalised true-power amplitude only in a basis orthonormal + in true power. A guide in the provider's own coordinates **is** that basis, so + this is :func:`_shot_noise` unchanged. A unitary change of basis draws + **once, in the reference basis, with the same seed and the same stream**, and + carries that one realisation into the run's coordinates through the inverse + transform (:func:`solprop.noise.transformed_noise`): a second draw in the declared + basis would be a different stochastic model, because a fixed-modulus random-phase + draw is not invariant in distribution under unitary mixing. + + - ``noise``: which model to use, or ``None`` for a noiseless run. + """ + drawn = _shot_noise(noise, grid, seed, n_modes=len(guide.mode_collection)) + sampling = guide.sampling + if drawn is None or sampling is None or sampling.basis is None: + return drawn + return transformed_noise(drawn, sampling.basis.transform) + + +def _spatial_shot_noise( + noise: NoiseModel, flux: SpatialFlux, n_pol: int, seed: int | None +) -> NoiseRealisation: + """Draw a radial or Cartesian run's shot noise at its entrance plane. + + - ``seed``: entropy of the generator; ``None`` draws from the operating system. + """ + return spatial_noise(flux, np.random.default_rng(seed), model=noise, n_pol=n_pol) + + +def _spatial_draw( + noise: NoiseModel, simulation: "Simulation", medium: SpatialMedium +) -> Callable[[int | None], NoiseRealisation | None]: + """Return the draw of a spatial run's shot noise, measured at its entrance plane.""" + flux = SpatialFlux.resolve( + simulation._base_grid, + cast("RadialGrid | FreeGrid | PlanarGrid", simulation.spatial_grid), + medium, + z=simulation.initial_z, + ) + return functools.partial( + _spatial_shot_noise, noise, flux, simulation._field0.shape[1] + ) + + +def _with_noise( + problem: PropagationProblem, noise: NoiseRealisation | None +) -> PropagationProblem: + """Return `problem` carrying `noise`, every other leaf shared by identity. + + A realisation reaches a translation only through the initial record and the + ``has_noise`` flag; what else consults it is a refusal keyed on the model, and a + replacement that changes the model is checked by its caller. + + - ``noise``: the realisation in true sqrt(W) on a modal route, or ``None`` for no + noise. + - Returns: the problem with the realisation in its internal, vacuum-normalised + amplitude. + """ + noise0 = None if noise is None else noise_spectrum(noise, problem.shape) + if noise0 is not None and problem.transform.geometry is None: + noise0 = power_amplitude( + noise0, power_weight_at(problem, problem.initial.z), inverse=True + ) + return dataclasses.replace( + problem, + initial=dataclasses.replace( + problem.initial, + noise0=noise0, + noise_model="none" if noise is None else noise.model, + ), + flags=dataclasses.replace(problem.flags, has_noise=noise0 is not None), + ) + + +def _redrawable( + simulation: "Simulation", + draw: Callable[[int | None], NoiseRealisation | None], + seed: int | None, +) -> "Simulation": + """Let a realisation drawn without a seed be drawn again from a chosen one. + + A call with ``seed=None`` records the operating-system entropy it drew, so the + same call made twice describes two runs. Keeping the draw lets a resume take the + seed its file recorded and a scan point take its own (:meth:`Simulation._reseed`); + a seeded realisation keeps the one it was given. + + - Returns: `simulation`, able to redraw its noise when `seed` was ``None``. + """ + if seed is None and simulation.noise is not None: + simulation._noise_redraw = draw + return simulation + + +def default_field_range(peak_field: float) -> tuple[float, float]: + """Return the field amplitudes an ionisation table covers, from the input's peak. + + The table is built in ``log`` field and its knots double until it is converged + (:func:`solprop.translate.ionisation.build_rate_table`), so a generous range costs + little; getting it wrong is what costs. Below ``field_min`` the rate is taken as + zero, which is right because every rate this package carries is exactly zero below + its threshold, and the threshold is where the lower limit ends up anyway. Above + ``field_max`` the table *holds its last value*, so an upper limit that is too low + is a silent clip of the rate exactly where ionisation matters most. + + Hence three decades below the input peak and thirty times above it. The lower + factor only has to reach under the rate's threshold, which for the noble gases at + the intensities a capillary reaches sits one to two decades below the peak. The + upper factor has to clear the self-compressed peak: a capillary compressing a pump + to a few optical cycles raises the on-axis field by a small single-digit factor + before ionisation-induced defocusing and loss clamp it, so thirty is roughly an + order of margin on that -- a thousand times in intensity. + + Parameters + ---------- + peak_field : float + Peak field amplitude of the input, V/m, > 0: on a capillary the largest + over the axis and the transverse nodes. + + Returns + ------- + tuple of float + Lower and upper field amplitude in V/m. + + Raises + ------ + DriverError + If the peak field is not positive, which means the input is empty. + + Examples + -------- + >>> from solprop.driver.inputs import default_field_range + >>> low, high = default_field_range(1e10) + >>> f"{low:.3g} {high:.3g}" + '1e+07 3e+11' + """ + if not peak_field > 0.0: + msg = ( + f"the input carries no field (peak {peak_field:.3e} V/m), so " + "the ionisation table has no range to cover; give " + "ionisation_field_range explicitly" + ) + raise DriverError(msg) + return (peak_field * _FIELD_RANGE_BELOW, peak_field * _FIELD_RANGE_ABOVE) + + +def _general_pulse( + incident: SpatialPulse | None, + channel: int | str | None, + shape: str, + *, + wavelength: float, + fwhm: float | None, + width: float | None, + energy: float | None, + peak_power: float | None, + phases: Sequence[float] | np.ndarray, + pulses: Sequence[Pulse] | None, +) -> GaussianPulse | SechPulse | PulseSum | SpatialPulse: + """Return a general modal run's input: a shaped pulse, or an incident beam. + + - ``incident``: the incident beam, which carries its own temporal pulse; ``None`` + builds the pulse from the keywords. + - ``shape``, ``wavelength``, ``fwhm``, ``width``, ``energy``, ``peak_power``, + ``phases``, ``pulses``: the pulse keywords, as :func:`_shaped_pulse` takes them. + """ + if incident is None: + return _shaped_pulse( + shape, + wavelength=wavelength, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phases=phases, + pulses=pulses, + ) + if channel is not None: + msg = ( + "channel= and incident= are two ways to say what is launched and this " + "call gives both: name a channel and the pulse enters it unchanged, or " + "declare an incident beam and the channels capture what they overlap" + ) + raise DriverError(msg) + given = [ + name + for name, value in ( + ("fwhm", fwhm), + ("width", width), + ("energy", energy), + ("peak_power", peak_power), + ("pulses", pulses), + ) + if value is not None + ] + if len(phases): + given.append("phases") + if shape != "gauss": + given.append("shape") + if given: + msg = ( + f"incident= carries its own temporal pulse, and this call also sets " + f"{', '.join(given)}: give the beam's pulse its duration, energy and " + "phase, and leave the pulse keywords unset" + ) + raise DriverError(msg) + if isinstance(incident, PlanarPulse): + msg = ( + "a PlanarPulse launches into a planar guide's slab modes; the incident " + "beam of a general modal guide is a SpatialPulse" + ) + raise DriverError(msg) + if incident.propz != 0: + msg = ( + f"incident= is launched {incident.propz!r} m from its waist; carrying a " + "beam to the guide's entrance is a transport step this launch does not " + "model, so the beam is launched at its waist (propz=0)" + ) + raise DriverError(msg) + return incident + + +_BEAM_EXTENT = 60.0 +"""Field exponent at the edge of an incident beam's radial plane. + +The plane extends to where a super-Gaussian of order N has fallen to +``exp(-_BEAM_EXTENT)`` in field, ``r = waist * _BEAM_EXTENT**(1/(2N))``, times the +margin :data:`_BEAM_MARGIN`; its intensity there is below 1e-52 of the peak. +""" + +_BEAM_MARGIN = 1.5 +"""Factor on the incident beam's plane radius beyond its decay radius.""" + +_BEAM_NODES = 256 +"""Radial nodes of an incident beam's plane. + +With the plane radius above, the largest transverse wavenumber is about +``pi * 256 / (1.5 * 60**(1/2N) * waist)``, 69/w for a Gaussian, where its spectrum +is below exp(-1000): the beam is resolved to rounding. +""" + + +def _beam_grid(pulse: SpatialPulse) -> RadialGrid: + """Return the radial plane an incident beam is sampled on before coupling.""" + radius = _BEAM_MARGIN * pulse.waist * _BEAM_EXTENT ** (1.0 / (2.0 * pulse.order)) + return RadialGrid(radius, _BEAM_NODES) + + +def _incident_launch( + simulation: "Simulation", + pulse: SpatialPulse, + model: CouplingModel, +) -> "Simulation": + """Couple an incident beam into a general modal simulation and install it. + + The beam becomes a radial :class:`~solprop.coupling.plane.FieldPlane` in the + guide's core index (:meth:`~solprop.coupling.plane.FieldPlane.from_pulse`, its + request met over its whole plane in the paraxial measure), and + :func:`~solprop.coupling.coupler.couple` recovers its coordinates in the guide's + channels under `model`. **Nothing is rescaled to the requested energy**: a + badly captured beam launches the little the channels take, and a beam they + cannot see is a valid zero launch. The launch is installed through + :meth:`Simulation.with_input`, which records its provenance as + ``configuration["coupling"]``; the coupling's report is recorded as + ``configuration["launch"]`` and the beam as ``configuration["pulse"]``. + """ + port = simulation.input_port() + index = ( + np.zeros(simulation.grid.n_omega) + if port.reference_index is None + else (np.asarray(port.reference_index)) + ) + try: + plane = FieldPlane.from_pulse( + pulse, + simulation.grid, + PlaneGeometry(port.geometry.z), + space=_beam_grid(pulse), + index=index, + ) + result = couple(plane, port, model=model) + except CouplingError as error: + raise DriverError(str(error)) from error + report = result.report + logger.info( + "incident beam coupled into %d channel(s) under %s: %.4g J incident, " + "%.4g J launched", + port.shape[0], + model.kind, + report.incident_energy, + report.launched_energy, + ) + launched = simulation.with_input( + result.launch, + noise=simulation.noise, + configuration={"pulse": pulse, "launch": report}, + ) + # The launch carries the constructor's realisation unchanged, so a draw made + # without a seed may still be drawn again from a recorded one. + launched._noise_redraw = simulation._noise_redraw + return launched + + +def _hollow_input( + pulse: Pulse, + launch: ModalLaunch | None, + grid: TimeGrid, + modes: Sequence[Mode], +) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], dict[str, object] | None]: + """Return a hollow guide's initial state, and its launch record if it has one. + + - ``launch``: the ``launch`` keyword; ``None`` puts the input in the first mode. + - Returns: ``configuration["modal_launch"]``, or ``None`` without a launch. + """ + if launch is None: + return _in_lowest_mode(pulse.build(grid), len(modes)), None + # A hollow guide's modes are orthonormal in true power, so the shares are energy + # fractions (SEP 41, S5). + return launch_pulse( + pulse, launch, grid, tuple(mode.channel_label for mode in modes) + ) + + +def _in_lowest_mode( + built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], n_modes: int +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a one-mode input placed in the lowest mode of an `n_modes` set. + + A single pulse enters the fundamental and the higher modes start empty, filling + through the nonlinearity. + """ + if built.shape[0] > n_modes: + msg = ( + f"the input carries {built.shape[0]} modes and the set has {n_modes}; a " + "pulse cannot be placed in a mode that is not propagated" + ) + raise DriverError(msg, parameter=("pulses", "modes")) + if built.shape[0] == n_modes: + return built + state = np.zeros((n_modes, *built.shape[1:]), dtype=np.complex128) + state[: built.shape[0]] = built + return state + + +def _capillary_input_peak( + field0: Complex[np.ndarray, "n_modes n_pol n_omega"], + modes: ModeCollection, + grid: TimeGrid, + rule: TransverseQuadrature | None, +) -> tuple[float, float]: + """Return the input's peak field, which sets the ionisation table's range. + + The peak is the largest field amplitude of the analytic signal over the axis and the + run's own transverse nodes, which are where the plasma response is evaluated. On the + axis it is the quantity :data:`solprop.stats.CATALOGUE`'s ``peak_intensity`` is the + square of, and for an ``HE_1m`` launch the axis is the peak, so the range is + anchored to a number the run itself reports. A ``TE_0m`` or ``TM_0m`` field vanishes + on the axis (azimuthal order zero, but an azimuthal or radial vector field), so for + those the nodes carry the peak. The same peak is the reference field of an optmat + rate's constant energy per event (SEP 3, D8). + + - ``modes``: the mode set, whose profiles are evaluated on the axis and at the + nodes. + - ``rule``: the run's transverse quadrature; ``None`` for a mode-averaged run, whose + table the axis alone bounds: the area-mean field it samples lies below it. + - Returns: the peak field amplitude in V/m, and the circular share ``|S3| / S0`` of + the field's transverse components where it peaks, zero for a linear field. + """ + analytic = analytic_field(grid, field0) + limits = modes.dimlimits()[0] + peak_power = 0.0 + circular = 0.0 + for where in ( + (on_axis_rule(limits),) if rule is None else (on_axis_rule(limits), rule) + ): + matrices = mode_matrix(modes.modes, where, 0.0, components=("x", "y")) + rows = matrices.synthesis.reshape(matrices.n_nodes, matrices.n_pol, -1) + # One node at a time: the field at every node and time together would be + # n_nodes times the state's size, which a full angular rule makes large. + for node in rows: + columns = np.tensordot(node, analytic, axes=([1], [0])) + power = np.sum(np.abs(columns) ** 2, axis=tuple(range(columns.ndim - 1))) + index = int(np.argmax(power)) + if float(power[index]) > peak_power: + peak_power = float(power[index]) + # The Stokes parameter of the x and y components at the peak sample. + x, y = columns.reshape(columns.shape[0], -1, columns.shape[-1])[ + :, 0, index + ] + circular = abs(2.0 * float(np.imag(x * np.conj(y)))) / peak_power + peak = math.sqrt(peak_power) + logger.debug("input peak field over the axis and the nodes %.4e V/m", peak) + return peak, circular + + +@dataclasses.dataclass(frozen=True) +class _InputPeak: + """What the input's peak field is found from, evaluated only when it is needed. + + Attributes + ---------- + field0 : numpy.ndarray + The input state, shape ``(n_modes, n_pol, n_omega)``. + modes : solprop.modes.collection.ModeCollection + The mode set. + grid : solprop.grid.TimeGrid + The grid the state is stored on. + rule : solprop.modes.quadrature.TransverseQuadrature or None + The run's transverse quadrature; ``None`` for a mode-averaged run. + """ + + field0: Complex[np.ndarray, "n_modes n_pol n_omega"] + modes: ModeCollection + grid: TimeGrid + rule: TransverseQuadrature | None + + def evaluate(self) -> tuple[float, float]: + """Return the peak field in V/m and its circular share.""" + return _capillary_input_peak(self.field0, self.modes, self.grid, self.rule) + + +def _ionisation_inputs( + responses: Sequence[object], + field_range: tuple[float, float] | None, + peak: _InputPeak | None, +) -> tuple[list[object], tuple[float, float] | None, float | None]: + """Return a capillary run's ionisation table range and its bound rates. + + The input's peak field is found once, when the table's range is the default one + (:func:`default_field_range`) or an optmat rate waits for its reference field. + + - ``field_range``: the caller's table range in V/m, or ``None`` for the default. + - ``peak``: where the input's peak is found from; ``None`` for a run without plasma. + - Returns: the responses (optmat rates bound to the peak), the table range, and the + reference field in V/m the rates were bound to, or ``None``. + """ + unbound = _unbound_rates(responses) + if peak is None or not (field_range is None or unbound): + return list(responses), field_range, None + found = peak.evaluate() + if field_range is None: + field_range = default_field_range(found[0]) + if not unbound: + return list(responses), field_range, None + return _bound_rates(responses, found, field_range), field_range, found[0] + + +def _check_rate_inputs( + simulation: "Simulation", + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, +) -> None: + """Check a run's input against each optmat rate it carries, once per run. + + The reference field and the table range stay those the run was built with; the + input is checked against each rate's maximum field and, when it is not linearly + polarised, the Keldysh parameter at its peak + (:meth:`solprop.nonlinear.optmat_rates.OptmatRate.check_input`). On a mode-set + route the peak and the polarisation are the field's over the axis and the nodes + (:func:`_capillary_input_peak`); on a free-space route the peak is the physical + intensity's maximum and the polarisation the pulse's Jones vector, which a + spectrum given as an array does not declare, so it is taken as linear there. + """ + rates = [ + one.rate + for one in simulation.responses + if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) + and isinstance(one.rate, OptmatRate) + ] + if not rates: + return + if simulation.spatial_grid is not None: + peak, circular = _spatial_input_peak(simulation, initial) + elif simulation.general is None and simulation.settings.quadrature is not None: + peak, circular = _capillary_input_peak( + simulation._field0, + ModeCollection(list(simulation.modes)), + simulation.grid, + simulation.settings.quadrature, + ) + else: + return + for rate in rates: + rate.check_input(peak, circular=circular) + + +def _bind_spatial_rates( + simulation: "Simulation", + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, +) -> None: + """Bind a free-space or planar run's optmat rates to its input, and check it. + + The spatial routes -- radial, Cartesian, line and planar -- bind an optmat rate's + constant energy per event to the input's peak field, as the capillary route does + (SEP 3, Q14); binding also checks the input against each rate. A run whose rates + are all bound is only checked. + """ + if _unbound_rates(simulation.responses): + simulation.responses = tuple( + _bound_rates( + simulation.responses, + _spatial_input_peak(simulation, initial), + simulation.settings.ionisation_field_range, + ) + ) + else: + _check_rate_inputs(simulation, initial) + + +def _spatial_input_peak( + simulation: "Simulation", + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, +) -> tuple[float, float]: + """Return a free-space or planar run's peak input field and its circular share. + + - Returns: the peak physical field amplitude in V/m, and the circular share + ``|S3|/S0`` of the pulse's Jones vector; a spectrum given as an array declares + none and is taken as linear. + """ + space = cast("RadialGrid | FreeGrid | PlanarGrid", simulation.spatial_grid) + # Ionisation is driven by the physical field, never by a signed axial flux. + peak = peak_field(simulation.grid, space, simulation._field0) + jones = ( + np.asarray(initial.jones, dtype=np.complex128) + if isinstance(initial, SpatialPulse | SpatialPulseSum) + else np.ones(1, dtype=np.complex128) + ) + circular = ( + abs(2.0 * float(np.imag(jones[0] * np.conj(jones[1])))) + / float(np.sum(np.abs(jones) ** 2)) + if jones.size == 2 + else 0.0 + ) + return peak, circular + + +def _unbound_rates(responses: Sequence[object]) -> bool: + """Return whether a plasma response carries an optmat rate without a reference. + + - Returns: True when a rate's constant energy per event still waits for the input's + peak. + """ + return any( + isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) + and isinstance(one.rate, OptmatRate) + and one.rate.reference_field is None + for one in responses + ) + + +def _bound_rates( + responses: Sequence[object], + peak: tuple[float, float], + field_range: tuple[float, float] | None, +) -> list[object]: + """Bind a run's optmat rates to the input's peak field, and check the input. + + - ``peak``: the input's peak field in V/m and its circular share there. + - ``field_range``: the field amplitudes in V/m the run tabulates its rates over. + - Warns once per run when the peak exceeds a rate's maximum field, or the input is + not linearly polarised at a Keldysh parameter above one + (:meth:`solprop.nonlinear.optmat_rates.OptmatRate.check_input`). + """ + field, circular = peak + bound = list(bind_reference_field(responses, field, field_range)) + for one in bound: + if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) and isinstance( + one.rate, OptmatRate + ): + one.rate.check_input(field, circular=circular) + return bound + + +def _borrowed_input( + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, *, stated: bool +) -> Pulse | SpatialPulse | SpatialPulseSum | np.ndarray: + """Return a runner's input as its private derived simulation may take it, uncopied. + + A derived simulation copies an array so that its record and its run outlive the + caller's changes. A runner's lives only until the load, on the caller's thread and + inside the call, so an array needs no copy unless the record outlives it: one + recorded inline stays a copy, in the handler's record. + + - Returns: a read-only view of a complex128 array the record does not hold, marked + as held so that nothing copies it; `initial` itself otherwise. The caller's own + flags are not touched. + """ + if ( + isinstance(initial, np.ndarray) + and initial.dtype == np.complex128 + and (stated or not _recorded_inline(initial)) + ): + return _hold(np.asarray(initial).view()) + return initial + + +def _without_input(problem: PropagationProblem) -> PropagationProblem: + """Return a loaded problem without its input, for the march that follows the load. + + The save-plane loop reads the launch position and never the launch, so the state + is replaced by a broadcast of one zero, which holds no volume (:class:`~solprop. + translate.problem.Initial`). The problem is good for the march alone. + """ + placeholder = np.broadcast_to( + np.zeros(1, dtype=np.complex128), problem.initial.field0.shape + ) + return dataclasses.replace( + problem, initial=dataclasses.replace(problem.initial, field0=_hold(placeholder)) + ) diff --git a/src/solprop/driver/interface.py b/src/solprop/driver/interface.py index dad614a4..0b00ba25 100644 --- a/src/solprop/driver/interface.py +++ b/src/solprop/driver/interface.py @@ -28,14 +28,10 @@ from solprop.boundaries import Absorber, SpatialAbsorber from solprop.coupling.models import CouplingModel -from solprop.driver.simulation import ( - DEFAULT_ATOL, - WAVEGUIDE_ATOL, - ModalLaunch, - NoiseSetting, - Simulation, - SimulationSettings, -) +from solprop.driver.defaults import DEFAULT_ATOL, WAVEGUIDE_ATOL +from solprop.driver.inputs import ModalLaunch +from solprop.driver.settings import NoiseSetting, SimulationSettings +from solprop.driver.simulation import Simulation from solprop.gasflow import VacuumEnd from solprop.grid import FreeGrid, RadialGrid, TimeGrid from solprop.linear import Frame @@ -123,7 +119,7 @@ def prop_radial( initial : SpatialPulse, SpatialPulseSum or numpy.ndarray Beam specification or internal Hankel spectrum in V m, shape ``(n_r, n_pol, n_omega)``. - settings : solprop.driver.simulation.SimulationSettings + settings : solprop.driver.settings.SimulationSettings Step tolerances, save planes, statistics and backend. frame : solprop.linear.Frame or None, optional Moving frame; None uses the responses' THG choice. @@ -246,7 +242,7 @@ def prop_free( initial : SpatialPulse, SpatialPulseSum or numpy.ndarray Beam specification or internal Cartesian DFT spectrum in V/m, shape ``(n_x*n_y, n_pol, n_omega)``. - settings : solprop.driver.simulation.SimulationSettings + settings : solprop.driver.settings.SimulationSettings Step tolerances, save planes, statistics and backend. frame : solprop.linear.Frame or None, optional Moving frame; None uses the responses' THG choice. @@ -367,7 +363,7 @@ def prop_planar( initial : solprop.pulses.PlanarPulse or numpy.ndarray The astigmatic launch, or an internal ``(p, k_y)`` spectrum in sqrt(W/m), shape ``(n_p*n_y, 1, n_omega)``. - settings : solprop.driver.simulation.SimulationSettings + settings : solprop.driver.settings.SimulationSettings Step tolerances, save planes, statistics and backend. frame : solprop.linear.Frame or None, optional Moving frame; None uses the responses' THG choice and the first mode. @@ -1066,7 +1062,7 @@ def prop_capillary( select components and terms, and a named unavailable model fails explicitly. n_r : int or None, optional Radial nodes of the fixed rule. ``None`` takes - :func:`solprop.driver.simulation.default_radial_order` of the mode count. + :func:`solprop.driver.routes.hollow.default_radial_order` of the mode count. n_theta : int or None, optional Azimuthal nodes. Refused for a set of purely radial modes, which need none. radial_rule : str, optional @@ -1079,7 +1075,7 @@ def prop_capillary( ionisation_field_range : tuple of float or None, optional Field amplitudes in V/m the rate table must cover. ``None`` derives them from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.simulation.default_field_range`. + :func:`solprop.driver.inputs.default_field_range`. absorber : solprop.boundaries.Absorber, False or None, optional The collar rates; ``None`` builds the default collar of the grid, and ``False`` switches both soft collars, temporal and spectral, off and keeps @@ -1571,7 +1567,7 @@ def prop_waveguide( n_r : int or None, optional Radial nodes of the fixed rule, or the x-axis nodes of a Cartesian product or slab rule. ``None`` takes - :func:`solprop.driver.simulation.default_radial_order` of the mode count, + :func:`solprop.driver.routes.hollow.default_radial_order` of the mode count, or :func:`~solprop.modes.quadrature.cartesian_nodes` of the set's highest transverse order where that is larger. n_theta : int or None, optional @@ -1589,7 +1585,7 @@ def prop_waveguide( ionisation_field_range : tuple of float or None, optional Field amplitudes in V/m the rate table must cover. ``None`` derives them from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.simulation.default_field_range`. + :func:`solprop.driver.inputs.default_field_range`. absorber : solprop.boundaries.Absorber, False or None, optional The collar rates; ``None`` builds the default collar of the grid, and ``False`` switches both soft collars, temporal and spectral, off and keeps diff --git a/src/solprop/driver/metadata.py b/src/solprop/driver/metadata.py new file mode 100644 index 00000000..c8ce98ea --- /dev/null +++ b/src/solprop/driver/metadata.py @@ -0,0 +1,449 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The record that says what produced a run, and whether it may be resumed. + +:func:`metadata_for` builds a run's :class:`solprop.output.schema.RunMetadata`: its +grid, modes, configuration, stepper, plane bases and recipe. The manual page is +``docs/guide/setup/running.md``. +""" + +import dataclasses +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Literal, Protocol, cast + +import numpy as np + +from solprop.coupling import ( + PlaneReference, + basis_identity, + modal_basis_identity, + space_record, +) +from solprop.driver.material_output import material_record, processing_record +from solprop.grid import FreeGrid, RadialGrid, TimeGrid +from solprop.linear import Frame, frame_origin +from solprop.modes.collection import transverse_measure +from solprop.modes.material import JsonValue +from solprop.modes.protocol import Mode +from solprop.modes.quadrature import TransverseQuadrature +from solprop.noise import NoiseRealisation +from solprop.output.schema import ( + DefaultsResolution, + GridRecord, + ModeRecord, + NoiseRecord, + NoiseResolution, + RecipeRecord, + RunMetadata, + StepLimits, + StepperRecord, +) +from solprop.physdata import Mixture +from solprop.physdata.selection import MaterialManifest +from solprop.spatial import SpatialMedium +from solprop.translate.coupling import frame_record +from solprop.translate.envelope_plasma import assembly_record +from solprop.translate.flux import flux_record +from solprop.translate.plasma_integration import integration_record +from solprop.translate.plasma_source import _BENCHMARK_GATED, plasma_source_record +from solprop.translate.problem import PropagationProblem, StepperSettings +from solprop.translate.responses import declares_harmonic_carrier +from solprop.translate.routes import flux_of, tdse_descriptor, transverse_of +from solprop.translate.run_request import RunRequest + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = ["RecipeSource", "metadata_for"] + + +class RecipeSource(Protocol): + """What built a simulation from a recipe: it writes the record its output keeps. + + :class:`solprop.recipe.core.Recipe` is one (SEP 41, S2); the driver needs only this. + """ + + def record(self, simulation: "Simulation") -> RecipeRecord: + """Return the authored and resolved recipe of a run about to start. + + Parameters + ---------- + simulation : Simulation + The simulation, translated, with its settings at run start. + + Returns + ------- + solprop.output.schema.RecipeRecord + The record its output keeps. + """ + ... + + +def _stepper_record(settings: StepperSettings) -> StepperRecord: + """Return the run's stepper record, naming an Adams integrator where one steps it. + + The method is recorded in full, and the interval cap and start-up rule with the + limits, so that a run and its resume hash the stepping they were taken with. + """ + return StepperRecord.from_settings( + settings.tableau, + settings.controller, + schedule=settings.schedule, + limits=StepLimits( + dz_min=settings.dz_min, + dz_max=settings.dz_max, + dz_init=settings.dz_init, + stretch=settings.stretch, + interval_cap=settings.interval_cap, + auto_init=settings.auto_init, + interpolate_planes=settings.interpolate_planes, + step_planes=settings.step_planes, + ), + method=None if settings.integrator is None else repr(settings.integrator), + ) + + +def metadata_for( + problem: PropagationProblem, + request: RunRequest, + grid: TimeGrid, + *, + modes: Sequence[Mode] = (), + configuration: Mapping[str, object] | None = None, + noise: NoiseRealisation | None = None, + modal: Mapping[str, JsonValue] | None = None, + materials: MaterialManifest | None = None, + quadrature: TransverseQuadrature | None = None, + frame: Frame | None = None, + noise_resolution: NoiseResolution | None = None, + defaults: DefaultsResolution | None = None, +) -> RunMetadata: + """Build the record that says what produced a run, and whether it may be resumed. + + Everything that describes *what is computed* goes into the digest + :meth:`solprop.output.schema.RunMetadata.configuration_hash` takes -- the grid, the + modes, the configuration, the noise and the stepper settings, plus the save planes + and the statistics, which are read off the run's request here -- and nothing that + describes *when* or *with which build*, so a resume survives an unrelated commit or + a NumPy upgrade. + + Parameters + ---------- + problem : solprop.translate.problem.PropagationProblem + The translated problem the run will step. + request : solprop.translate.run_request.RunRequest + The request translation returned with it: its stepper settings, save planes + and statistics enter the hash exactly as they did when the problem carried + them. + grid : solprop.grid.TimeGrid + The grid the state lives on. + modes : collections.abc.Sequence, optional + The propagated modes, in state order. + configuration : collections.abc.Mapping, optional + The physics objects that built the run -- responses, the input, profiles, + boundaries -- recorded by :func:`solprop.output.schema.serialise`. Nothing is + dropped: a callable is recorded as its ``repr`` and marked unreproducible, which + also makes the digest unstable across processes, so prefer objects that carry + their data (``docs/guide/setup/running.md``). + noise : solprop.noise.NoiseRealisation or None, optional + The realisation the run carries, recorded with its seed. + modal : collections.abc.Mapping or None, optional + The general modal identity, as + :func:`solprop.translate.general.modal_identity` writes it: the channel + labels, the table, metric, response and guide identities, the frame, the + active band and the accessors the basis admits. ``None`` for every other + route. It is part of the resume key. + materials : solprop.physdata.selection.MaterialManifest or None, optional + The run's material manifest + (:func:`solprop.driver.material_output.material_manifest`); ``None`` is the + empty manifest of a run of solprop's own materials. Its identities and + policies are part of the resume key. + quadrature : solprop.modes.quadrature.TransverseQuadrature or None, optional + The transverse rule of a resolved modal run, recorded with its nodes and + physical weights on the first mode's cross-section and part of the resume + key (SEP 19). ``None`` for every other route. + frame : solprop.linear.Frame or None, optional + The run's frame, whose reference frequency is the origin of the linear frame + term on an envelope grid; ``None`` is the grid's co-moving frame. It enters + the plane-reference record (SEP 31), not the hash. + noise_resolution : solprop.output.schema.NoiseResolution or None, optional + How the noise model was chosen (SEP 37). Outside the hash. + defaults : solprop.output.schema.DefaultsResolution or None, optional + Which stepping choices took the route's default. Outside the hash. + + Returns + ------- + solprop.output.schema.RunMetadata + The record, ready for :meth:`solprop.output.schema.Output.open`. + + Examples + -------- + >>> from solprop.driver.metadata import metadata_for + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.nonlinear.gnlse import problem_from + >>> from solprop.pulses import SechPulse + >>> from solprop.stepper import PIController + >>> from solprop.translate.problem import assemble + >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1e-12) + >>> gnlse = problem_from([0.0, 0.0, -1.2e-26], omega_ref=grid.omega0, gamma=0.11) + >>> start = SechPulse(wavelength=835e-9, width=1e-13, peak_power=1.0).build(grid) + >>> problem, request = assemble(grid, gnlse.mode, [gnlse.kerr], start, + ... controller=PIController(rtol=1e-8, atol=1e-14), + ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3, + ... density=1.0, shock=False, prefactor_index=1.0) + >>> metadata = metadata_for(problem, request, grid, modes=[gnlse.mode]) + >>> metadata.simulation_kind, len(metadata.configuration_hash()) + ('mode_averaged', 64) + """ + settings = request.stepper + record = dict(configuration or {}) + if bool(problem.responses.plasma_channels): + # The equation and admission policy are identity; the measured residual + # belongs to backend execution evidence, like the TDSE certificate. Its + # last bits may change with the host FFT implementation. + source = plasma_source_record(problem.grids) + identity = {key: value for key, value in source.items() if key != "certificate"} + if _BENCHMARK_GATED.get(): + identity["form"] = "gated_benchmark" + record["_benchmark_plasma_source"] = "gated_nonresumable" + record["plasma_source"] = identity + # The planes and the statistics come from the request rather than from the caller: + # a resume continues at a plane index, so two runs that differ in either are not the + # same run, and neither is carried by StepperRecord. + record["save_planes"] = np.asarray(settings.save_planes, dtype=np.float64) + record["statistics"] = request.observation.stats + measure = transverse_measure(modes) if modes else "area" + geometry = problem.transform.geometry + if geometry is not None and geometry.kind == "free" and geometry.shape[1] == 1: + # A Cartesian line (one y row of unit measure) is per metre of y however the + # simulation was built, not only through from_free. + measure = "per_unit_width" + if measure != "area": + # A run uniform in y reports its powers per metre of y; processing and + # plotting label them from this key (RunMetadata.transverse_measure). It is + # written only where it is not the default, so every area run keeps the + # digest it was stamped with. + record["transverse_measure"] = measure + if problem.responses.envelope_plasma is not None: + record["statistic_rate_conventions"] = { + name: "cycle-averaged" + for name in ("peak_ionisation_rate", "species_peak_ionisation_rate") + if name in request.observation.stats.names + or name in request.observation.stats.periodic + } + record["envelope_plasma"] = assembly_record( + problem.responses.envelope_plasma, problem.grids.omega + ) + if problem.responses.plasma_integral.policy != "trapezoid": + # The band rule's taps and policies (SEP 18, Q9): a resume that would + # integrate the plasma differently is refused through the hash. + record["plasma_integration"] = integration_record( + problem.responses.plasma_integral + ) + material = record.get("medium") + if isinstance(material, SpatialMedium): + record["spatial_medium"] = material + material = material.material + if not isinstance(material, Mixture) and modes: + material = getattr(modes[0], "mixture", None) + if isinstance(material, Mixture): + record["medium"] = material_record(material) + record["mixture_processing"] = processing_record( + problem, request.observation.save_planes, grid, material, modes + ) + if problem.linear.separable is not None: + # A Cartesian checkpoint must not resume with changed cutoff bins. + record["spatial_arithmetic"] = "split-square-v1" + geometry = problem.transform.geometry + spatial_record: dict[str, object] | None = None + if geometry is not None: + # A hybrid planar column is a slab amplitude per y frequency: its x axis is the + # slab rule's nodes, its spectrum the mode index and k_y, and its stored unit a + # modal amplitude's. + slab = geometry.kind == "slab" + spatial_record = { + "kind": geometry.kind, + "shape": geometry.shape, + "axes": geometry.axes, + "axis_names": ("r",) if geometry.kind == "radial" else ("x", "y"), + "axis_units": "m", + "spectral_axes": geometry.spectral_axes, + "spectral_axis_names": ("k_r",) + if geometry.kind == "radial" + else ("k_y",) + if slab + else ("k_x", "k_y"), + "spectral_axis_units": "rad/m", + "column_order": "r" + if geometry.kind == "radial" + else "p + n_p*iy" + if slab + else "ix + n_x*iy", + "physical_field_units": "V/m", + "stored_field_units": "V m" + if geometry.kind == "radial" + else "sqrt(W/m)" + if slab + else "V/m", + "spatial_weights": geometry.spatial_weights, + "spectral_weights": geometry.spectral_weights, + "forward_scale": geometry.forward_scale, + "inverse_scale": geometry.inverse_scale, + "window": geometry.window, + "absorber_rate": geometry.absorber_rate, + "flux_model": "scalar-axial-admittance", + "flux_plan": flux_record(flux_of(problem.route)), + } + descriptor = tdse_descriptor(problem.route) + tdse_record = None if descriptor is None else dataclasses.asdict(descriptor) + design = transverse_of(problem.route) + return RunMetadata( + reference=_plane_reference_record( + problem, request, grid, record, frame=frame, modes=modes, modal=modal + ), + transverse=None if design is None else design.record(), + grid=GridRecord.from_grid(grid), + modes=tuple(ModeRecord.from_mode(mode) for mode in modes), + simulation_kind=( + geometry.kind + if geometry is not None + else "mode_averaged" + if problem.flags.mode_averaged + else "multimode" + ), + configuration=record, + stepper=_stepper_record(settings), + noise=None if noise is None else NoiseRecord.from_realisation(noise), + noise_resolution=noise_resolution, + defaults=defaults, + spatial=spatial_record, + quadrature=None + if quadrature is None or not modes + else quadrature.record(modes[0].dimlimits(0.0)), + modal=None if modal is None else dict(modal), + materials=MaterialManifest() if materials is None else materials, + initial_z=problem.initial.z, + tdse=tdse_record, + ) + + +def _frame_follows_the_electronic_rule( + responses: Sequence[object], grid: TimeGrid +) -> bool: + """Return whether a run's co-moving frame changed when it stopped reading Raman. + + Before the frame followed + :func:`solprop.translate.responses.declares_harmonic_carrier`, any response's + ``thg`` kept ``beta_0`` on an envelope grid. The two rules disagree exactly when a + response other than the electronic ones declares ``thg=True`` and no electronic one + does. + + - Returns: True when the earlier rule would have kept ``beta_0`` and this one + removes it. + """ + return ( + grid.kind == "envelope" + and any(bool(getattr(one, "thg", False)) for one in responses) + and not declares_harmonic_carrier(responses, grid) + ) + + +def _plane_reference_record( + problem: PropagationProblem, + request: RunRequest, + grid: TimeGrid, + configuration: Mapping[str, object], + *, + frame: Frame | None, + modes: Sequence[Mode], + modal: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + """Return the plane-reference record of a run (SEP 31). + + - ``configuration``: the run's configuration; a ``coupling`` entry supplies the + inherited origin and the parent's provenance, and a ``spatial_grid`` the + transverse basis. + - ``frame``: the run's frame, or ``None`` for the co-moving one. + - Returns: ``carrier``, ``origin``, ``parent``, ``frame`` and ``basis``, as + :attr:`solprop.output.schema.RunMetadata.reference` documents them. + """ + # The co-moving frame keeps the carrier phase exactly when a harmonic term is + # present, and then measures its delay from zero frequency (`frame_origin`). + # `assemble` chose it from the declared responses and checked that choice against + # the compiled carrier, so reading the compiled one here records the same frame. + if frame is None: + frame = Frame.comoving(grid, thg=problem.responses.harmonic_carrier) + coupling = configuration.get("coupling") + origin: object = PlaneReference().record() + parent: object = None + if isinstance(coupling, Mapping): + coupled = cast("Mapping[str, object]", coupling) + origin = coupled["origin"] + parent = coupled["source"] + return { + "carrier": float(grid.omega0), + "origin": origin, + "parent": parent, + "frame": frame_record(problem.linear, frame_origin(frame, grid.kind)), + "basis": _plane_bases(problem, request, grid, configuration, modes, modal), + } + + +def _plane_bases( # noqa: PLR0917 - called positionally + problem: PropagationProblem, + request: RunRequest, + grid: TimeGrid, + configuration: Mapping[str, object], + modes: Sequence[Mode], + modal: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + """Return a run's route and its basis identity at each planned plane. + + A spatial basis is its transverse grid and a general one its checked reference, + the same at every plane; an ordinary mode set's basis is evaluated at each plane + (:func:`solprop.coupling.plane.modal_basis_identity`), because a guide may vary, + and once for a set whose every mode is ``z_constant``. + + - ``configuration``: the run's configuration, carrying a spatial run's + ``spatial_grid``. + - Returns: ``route``, ``z`` (the launch plane first, then the save planes, m) and + ``identity`` (one digest per plane, ``None`` on a planar run). + """ + planes = [ + float(problem.initial.z), + *map(float, request.observation.save_planes), + ] + geometry = problem.transform.geometry + identity: str | None + if geometry is not None: + route = {"radial": "radial", "free": "cartesian", "slab": "planar"}[ + geometry.kind + ] + space = configuration.get("spatial_grid") + identity = ( + basis_identity(cast("Literal['radial']", route), space_record(space)) + if isinstance(space, RadialGrid | FreeGrid) + else None + ) + identities = [identity] * len(planes) + elif modal is not None and modal.get("basis") == "general_modal": + route = "general_modal" + metric = cast("Mapping[str, JsonValue]", modal.get("metric") or {}) + record = { + "channels": modal.get("channels"), + "tables": modal.get("tables"), + "guide": modal.get("guide"), + "metric": {"kind": metric.get("kind"), "gauge_id": metric.get("gauge_id")}, + } + identities = [basis_identity("general_modal", record)] * len(planes) + elif all(mode.z_constant for mode in modes): + # A guide that is the same at every plane has one basis there: evaluated once, + # since a solved mode (a step-index fibre's) costs a mode solve per evaluation. + route = "modal" + identities = [modal_basis_identity(modes, grid, planes[0])] * len(planes) + else: + route = "modal" + identities = [modal_basis_identity(modes, grid, z) for z in planes] + return {"route": route, "z": planes, "identity": identities} diff --git a/src/solprop/driver/resume.py b/src/solprop/driver/resume.py new file mode 100644 index 00000000..e216a8a1 --- /dev/null +++ b/src/solprop/driver/resume.py @@ -0,0 +1,422 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Continue a run from the file it wrote: the resume decision and the restart. + +A resumed run reads its file's last plane, checks that it may continue from it, and +loads the backend at that plane, with the history it needs for its stepper. The manual +page is ``docs/guide/setup/running.md``. +""" + +import logging +import warnings +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +import numpy as np + +from solprop.backends.ledger import CallLedger +from solprop.backends.protocol import Backend +from solprop.backends.session import Start +from solprop.driver.inputs import _realisation +from solprop.driver.run import RESTART_RECORD, retention_at +from solprop.exceptions import DriverError, SolpropWarning +from solprop.fourier import power_amplitude +from solprop.nonlinear.tdse import TDSEExecution +from solprop.output.hdf5 import HDF5Output, ResumeState, _stored_metadata, check_cache +from solprop.output.hdf5 import resume as resume_state +from solprop.output.memory import MemoryOutput +from solprop.output.schema import MarchSnapshot, Output, RunMetadata +from solprop.stepper.low_storage import LowStorageRungeKutta +from solprop.stepper.multistep import Multistep +from solprop.translate.linop import power_weight_at +from solprop.translate.problem import PropagationProblem +from solprop.translate.routes import tdse_descriptor +from solprop.translate.run_request import RunRequest +from solprop.translate.tdse import AtomicCallLedger + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [] + +logger = logging.getLogger(__name__) + +type _ResumeChoice = bool | Literal["interpolated"] +"""What ``resume`` takes: no, yes, or from a plane interpolated inside a step.""" + + +def _adopt_recorded_seed( + simulation: "Simulation", output: Output | Path | str | None +) -> None: + """Take the noise seed of the file a resume continues, where the call gave none. + + The maintainers' ruling on resuming a default call (``seed=None``): the + realisation is drawn again from the seed the file recorded, so the re-run + call describes the run it continues. Nothing happens when there is no file, + the file recorded no seed or another noise model, or the realisation was + seeded or supplied by the caller; :func:`~solprop.output.hdf5.check_cache` + then judges the run as before. + """ + if ( + simulation._noise_redraw is None + or simulation.noise is None + or not isinstance(output, Path | str) + or not Path(output).exists() + ): + return + stored = _stored_metadata(output)[0].noise + if stored is None or stored.seed is None or stored.model != simulation.noise.model: + return + if simulation._reseed(stored.seed): + logger.info( + "resuming %s: the noise was drawn without a seed, so it is drawn again " + "from the seed the file recorded, %d", + output, + stored.seed, + ) + + +def _start( + simulation: "Simulation", + backend: Backend, + problem: PropagationProblem, + state: ResumeState | None, + *, + kept: bool = False, + restoration: dict[str, object] | None = None, +) -> Start: + """Return where the run starts: the entrance, or the state a file resumes. + + The run's session loads it (SEP 47): the field, the noise, a resume's call + ledgers and the method history this run continues. + + - ``backend``: the backend about to step, asked only whether its march can carry + a method history. + - ``state``: what a file the run is continuing holds, or ``None`` to start at + the entrance. + - ``kept``: the backend was built for another input, so this one's noise has to + be handed to it. + """ + if state is None: + return Start.entrance(problem, noise=_realisation(problem) if kept else None) + request = simulation._run_request() + next_plane = _validate_resume( + problem, request, state, simulation.settings.tdse_execution + ) + logger.info( + "resuming at z = %.6e m, plane %d of %d", + state.plane.z, + state.n_saved, + request.observation.save_planes.size + 1, + ) + # A field-free plane resumes only a finished run, which never gets here. + assert state.plane.field is not None + field = state.plane.field + if problem.transform.geometry is None: + field = power_amplitude( + field[np.newaxis], power_weight_at(problem, state.plane.z), inverse=True + )[0] + integrator = request.integration.method + record, march = _continue_march( + simulation, + backend, + state, + adams=isinstance(integrator, Multistep), + low_storage=isinstance(integrator, LowStorageRungeKutta), + ) + if restoration is not None: + restoration.update(record) + return Start( + field=field, + z=float(state.plane.z), + plane_index=next_plane, + march=march, + ledgers=None + if state.outcome is None + else { + "logical": state.outcome.logical_calls, + "atomic": state.outcome.atomic_calls, + }, + resume=True, + ) + + +def _continue_march( + simulation: "Simulation", + backend: Backend, + state: ResumeState, + *, + adams: bool = True, + low_storage: bool = False, +) -> tuple[dict[str, object], MarchSnapshot | None]: + """Decide whether the Adams history continues from the file's snapshot. + + Says what is restored, for the resumed execution record (SEP 47 section 5.5), + and warns once when an Adams history the run expected is not restored: + omitted by the automatic size limit, or kept where this backend's march + cannot carry it. A history set aside on request (``resume_history=False``) is + recorded without a warning. A history handed on is installed by the run's + session, which warns and records a reset if it cannot + (:func:`solprop.driver.run.run_problem`). + + - Returns ``record``: ``"state"`` (``"restored"``, ``"reset"`` or + ``"not_applicable"``), ``"reason"`` and ``"z"``, m; for an Adams method also + ``"retention"``, what the file states it kept there, ``{"state": "unknown"}`` + where it states nothing. + - Returns ``march``: the history the run's session is to install, or ``None``. + """ + z = state.plane.z + if low_storage: + record = {"state": "not_applicable", "reason": "no_method_history", "z": z} + return record, None + if not adams: + # The proposal and the norm memory start afresh (issue #596). + return {"state": "reset", "reason": "unsupported_method", "z": z}, None + execution = None if state.outcome is None else state.outcome.execution + recorded = retention_at(execution, z) + retention: dict[str, object] = ( + dict(recorded) + if recorded is not None + else { + "state": "unknown", + # A file from before the restart record, or one that states nothing + # at this position: never an invented reason. + "reason": "legacy" + if execution is None or RESTART_RECORD not in execution + else "not_recorded", + } + ) + + def reset(reason: str) -> dict[str, object]: + """Return the restoration record of a history that starts afresh. + + - Returns: the record's ``"state"``, ``"reason"``, ``"z"`` and + ``"retention"``. + """ + return {"state": "reset", "reason": reason, "z": z, "retention": retention} + + if simulation.settings.resume_history is False: + if state.march is not None: + logger.info("the Adams history starts afresh: resume_history=False") + return reset("explicit_policy"), None + # A march that cannot carry its history restarts it without having been asked + # to: say so once, as a warning (SEP 29 section 6.6). + refusal = backend.march_export_refusal() + if refusal is not None: + warnings.warn( + f"the Adams history starts afresh: {refusal}", + SolpropWarning, + stacklevel=2, + ) + return reset("incompatible_representation"), None + if state.march is None: + return _missing_history(retention, reset), None + restored: dict[str, object] = { + "state": "restored", + "reason": "retained_history", + "z": z, + "retention": retention, + } + return restored, state.march + + +def _missing_history( + retention: Mapping[str, object], + reset: Callable[[str], dict[str, object]], +) -> dict[str, object]: + """Record, and warn about, an Adams resume whose file holds no history.""" + kept, why = retention.get("state"), retention.get("reason") + if kept == "omitted" and why == "automatic_size_limit": + size = retention.get("nbytes") + limit = retention.get("limit") + warnings.warn( + "the Adams history starts afresh: the run did not keep it for a " + "resume, " + + ("" if size is None else f"{size} bytes being ") + + "over the " + + ("automatic limit" if limit is None else f"{limit} bytes") + + " kept by default (resume_history=True keeps it)", + SolpropWarning, + stacklevel=3, + ) + return reset("automatic_size_limit") + lost: tuple[str, str] | None = None + if kept == "retained": + lost = ("kept it here, but it cannot be read back", "unavailable_history") + elif kept == "omitted" and why == "write_failed": + lost = ("was keeping it here when the write failed", "unavailable_history") + elif kept == "omitted" and why == "incompatible_representation": + lost = ( + "could not keep it: the run's engine does not export it", + "incompatible_representation", + ) + if lost is not None: + warnings.warn( + f"the Adams history starts afresh: the file {lost[0]}", + SolpropWarning, + stacklevel=3, + ) + return reset(lost[1]) + if kept == "omitted" and why == "explicit_policy": + return reset("explicit_policy") + # Nothing to continue (a position the march held no history at), or a file + # that does not say: as before, the history starts afresh without a warning. + return reset("unavailable_history") + + +def _validate_resume( + problem: PropagationProblem, + request: RunRequest, + state: ResumeState, + execution: TDSEExecution | None, +) -> int: + """Validate restart position and budgets before preparing atomic resources. + + - ``request``: its run's request, with the absolute save positions in m. + - ``execution``: current atomic budget, if this is a TDSE run. + - Returns: index of the next scheduled save strictly beyond the accepted position. + """ + planes = request.observation.save_planes + next_plane = int(np.searchsorted(planes, state.plane.z, side="right")) + if state.checkpoint is not None and state.checkpoint.next_save_index != next_plane: + raise DriverError( + "checkpoint next_save_index disagrees with its absolute position" + ) + if not problem.initial.z <= state.plane.z <= planes[-1]: + raise DriverError( + "resume position lies outside the declared propagation interval" + ) + if state.plane.field is None: + if _work_left(request, state, next_plane): + msg = ( + f"the run's last saved plane, at z = {state.plane.z:.6e} m, kept no " + "field (it was saved with fields='none' or 'planes') and the file " + "holds no terminal checkpoint, which always carries the field, so " + "there is no state to continue from: a run stopped by an error or a " + "signal writes one, a run killed outright does not. Rerun it, or keep " + "the field on its last plane" + ) + raise DriverError(msg) + elif not np.all(np.isfinite(state.plane.field)): + raise DriverError("resume requires a finite stored field") + outcome = state.outcome + if tdse_descriptor(problem.route) is not None and ( + outcome is None or not outcome.logical_calls or not outcome.atomic_calls + ): + raise DriverError("a TDSE resume requires persisted cumulative call ledgers") + method = request.integration.method + if outcome is not None and outcome.logical_calls and isinstance(method, Multistep): + calls = CallLedger.from_record(outcome.logical_calls) + if method.max_calls is not None and calls.total > method.max_calls: + raise DriverError("logical call budget is below previously charged work") + if outcome is not None and outcome.atomic_calls: + atomic = AtomicCallLedger.from_record(outcome.atomic_calls) + if tdse_descriptor(problem.route) is None or execution is None: + raise DriverError( + "an atomic call ledger requires an explicit TDSE execution" + ) + if ( + execution.max_atomic_calls is not None + and atomic.attempted > execution.max_atomic_calls + ): + raise DriverError("atomic call budget is below previously attempted work") + return next_plane + + +def _interpolated(state: ResumeState) -> bool: + """Return whether the plane a file resumes from was interpolated inside a step. + + A landed plane is the end of an accepted step of the run; an interpolated one lies + inside a step. The entrance is the initial field. + """ + return state.interpolated + + +def _open_output( + output: Output | Path | str | None, + metadata: RunMetadata, + *, + resume: _ResumeChoice, +) -> tuple[Output, ResumeState | None]: + """Choose the handler a run writes to, and read what a resumed file holds. + + The resume gate (ADR-066): the file's configuration hash must equal `metadata`'s. + Whether work is left is decided by :func:`_continues`. + + - Returns: the handler, and the state to resume from or ``None``. + """ + if resume and metadata.configuration.get("_benchmark_plasma_source"): + raise DriverError("gated plasma benchmark output cannot be resumed") + if isinstance(output, Path | str): + path = Path(output) + if resume and check_cache(path, metadata): + return HDF5Output(path, append=True), resume_state(path) + return HDF5Output(path), None + if resume: + msg = ( + "only a path can be resumed: the file has to be opened in append mode, " + "which is the driver's to decide; pass output= with resume=True" + ) + raise DriverError(msg) + return (MemoryOutput() if output is None else output), None + + +def _check_resume_choice(resume: object) -> None: + """Refuse a ``resume`` value that is not one of its three. + + A mistyped word would otherwise read as ``True``. + """ + if resume is True or resume is False or resume == "interpolated": + return + msg = f"resume must be False, True or 'interpolated', got {resume!r}" + raise ValueError(msg) + + +def _work_left(request: RunRequest, state: ResumeState, next_plane: int) -> bool: + """Return whether a file has anything left to compute, by position. + + Decided by position, not status: a file killed after its last plane has no work + left, though its status reads ``running``; a terminal checkpoint still owes its + plane. + """ + return ( + state.checkpoint is not None + or next_plane < request.observation.save_planes.size + ) + + +def _refuse_interpolated(path: Path, state: ResumeState, resume: _ResumeChoice) -> None: + """Refuse to continue from a plane interpolated inside a step, unless accepted. + + An interpolated plane is not a state the march passed through, so a run restarted + there is not the run it continues (SEP 14 section 5); ``resume="interpolated"`` + accepts that. + """ + if resume == "interpolated" or not _interpolated(state): + return + msg = ( + f"{path} ends on a plane interpolated inside a step at z = " + f"{state.plane.z:.6e} m, not a state the march passed through; " + "pass resume='interpolated' to continue afresh from it, or rerun" + ) + raise DriverError(msg) + + +def _continues( + path: Path, + request: RunRequest, + state: ResumeState, + next_plane: int, + resume: _ResumeChoice, +) -> bool: + """Say whether a resume of a file computes anything, once its hash has passed. + + - Returns: ``True`` when work is left, and ``False`` for a finished file. + """ + if not _work_left(request, state, next_plane): + return False + _refuse_interpolated(path, state, resume) + return True diff --git a/src/solprop/driver/routes/__init__.py b/src/solprop/driver/routes/__init__.py new file mode 100644 index 00000000..9e695140 --- /dev/null +++ b/src/solprop/driver/routes/__init__.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The keyword routes that build a :class:`solprop.driver.simulation.Simulation`. + +One module per family of constructor: :mod:`~solprop.driver.routes.gnlse`, +:mod:`~solprop.driver.routes.hollow` (capillaries and supplied hollow guides), +:mod:`~solprop.driver.routes.general` (general modal guides) and +:mod:`~solprop.driver.routes.spatial` (free space and planar grids), with the material +selection they share in :mod:`~solprop.driver.routes.materials` and the naming of the +keyword a refusal concerns in :mod:`~solprop.driver.routes.keywords`. +""" diff --git a/src/solprop/driver/routes/general.py b/src/solprop/driver/routes/general.py new file mode 100644 index 00000000..132e5cdc --- /dev/null +++ b/src/solprop/driver/routes/general.py @@ -0,0 +1,493 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The general modal route: a supplied guide's modes and its keywords.""" + +import functools +import logging +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Literal, cast + +import numpy as np +from jaxtyping import Complex, Float + +from solprop.boundaries import Absorber +from solprop.coupling import CouplingModel +from solprop.driver._launch import launch_pulse, refuse_beside +from solprop.driver.defaults import resolve_noise +from solprop.driver.inputs import ( + ModalLaunch, + _general_noise, + _general_pulse, + _incident_launch, + _redrawable, +) +from solprop.driver.routes.hollow import _capillary_grid +from solprop.driver.routes.materials import _selected_guide +from solprop.driver.settings import ( + NoiseSetting, + SimulationSettings, + _boundary, + _keyword_controller, + _save_planes, +) +from solprop.exceptions import DriverError, TranslationError +from solprop.grid import TimeGrid +from solprop.modes.guide import ModalGuide +from solprop.modes.material import MaterialRegion +from solprop.modes.protocol import Mode +from solprop.monitor import BoundaryCheck +from solprop.output.schema import ModeRecord +from solprop.physdata.selection import MaterialSelection +from solprop.pulses import Pulse, SpatialPulse +from solprop.stats import StatsSpec +from solprop.stepper.low_storage import LowStorageRungeKutta +from solprop.stepper.multistep import Multistep +from solprop.stepper.runge_kutta import RungeKutta +from solprop.stepper.schedule import FixedSchedule +from solprop.translate.coupled import metric_at +from solprop.translate.general import general_modal_route +from solprop.translate.masks import AbsorberScheme +from solprop.translate.responses import KerrForm + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [] + +logger = logging.getLogger(__name__) + + +def _from_general( + cls: type["Simulation"], + provider: ModalGuide, + modes: Sequence[Mode], + length: float, + *, + channel: int | str | None, + incident: SpatialPulse | None, + coupling: CouplingModel | None, + kerr_form: KerrForm, + observable_rtol: float | None, + observable_atol: float | None, + wavelength: float, + wavelength_limits: tuple[float, float] | None, + trange: float | None, + grid: TimeGrid | None, + fwhm: float | None, + width: float | None, + shape: str, + energy: float | None, + peak_power: float | None, + phases: Sequence[float] | np.ndarray, + pulses: Sequence[Pulse] | None, + absorber: Absorber | Literal[False] | None, + absorber_scheme: AbsorberScheme | None, + boundary_check: BoundaryCheck | None, + noise: NoiseSetting, + seed: int | None, + rtol: float | None, + atol: float, + dz_max: float | None, + dz_init: float | None, + schedule: FixedSchedule | None, + save_planes: Sequence[float] | np.ndarray | None, + n_save: int | None, + stats: StatsSpec | None, + backend: str, + materials: MaterialSelection | None = None, + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, + launch: ModalLaunch | None = None, + interpolate_planes: bool = False, + step_planes: int | None = None, + polarisation_taper: bool = False, + **refused: object, +) -> "Simulation": + """Build a general modal run from a guide that solves its own fields. + + The provider half of :meth:`from_waveguide`, kept apart because it shares no + physics with the gas-filled one: no fill, no mixture, no static transverse + quadrature, no declared response list, and a launch that crosses no + normalisation boundary. Every keyword of the gas-filled path arrives in + `refused` and is refused by name if it was set. + + - ``length``: guide length, m. + - ``channel``: which channel the pulse enters; ``None`` takes the first. + - ``observable_rtol``, ``observable_atol``: accepted only as ``None``, which + means the solver's own tolerances. + - ``fwhm``, ``width``, ``shape``, ``energy``, ``peak_power``, ``phases``, + ``pulses``: the input pulse, as :func:`_shaped_pulse` takes it. + - ``absorber``: the collar rates; ``None`` builds the grid's default collar, and + ``False`` switches both soft collars off and keeps the hard band mask. + - ``absorber_scheme``: how the temporal collar is applied; ``None`` is the + default scheme. + - ``stats``: which statistics to record; ``None`` takes :func:`_general_stats`. + - ``integrator``: the method that steps the run; ``None`` takes ``Multistep()``, + and ``LowStorageRungeKutta()`` replays `schedule` in three registers. + - ``step_planes``: also save the end of every `step_planes`-th accepted step; + ``None`` is ``0``. + - ``**refused``: every gas-filled keyword; each is refused by name when it is + set. + """ + _refuse_gas_keywords(refused) + provider = _selected_guide( + provider, MaterialSelection() if materials is None else materials + ) + for name, value in ( + ("observable_rtol", observable_rtol), + ("observable_atol", observable_atol), + ): + if value is not None: + msg = ( + f"{name}={value!r}: a general modal run's observables are its " + "channel amplitudes in a basis orthonormal in true power, so " + "the controller's own rtol and atol already bound them, and " + "the separate allocation a different number would ask for -- " + "over the table's frequency interpolation, the transverse rule " + "and the step -- is not implemented. Pass None, and set rtol " + "and atol" + ) + raise DriverError(msg) + working = _capillary_grid( + grid, + length, + wavelength, + wavelength_limits, + trange, + envelope=False, + thg=False, + ) + if working.kind != "field": + msg = ( + f"a general modal guide propagates on a field grid and this run is " + f"on an {working.kind} grid: its leaves are tabulated on the " + "canonical increasing coarse axis, and its source is the " + "analytic-signal polarisation of a carrier-resolved field" + ) + raise DriverError(msg) + channels = tuple(provider.mode_collection.modes) + if tuple(modes) not in ((), channels): + msg = ( + f"a general modal guide supplies its own {len(channels)} ordered " + f"channel(s), validated against the provider's fields, and this " + f"call passes {len(tuple(modes))} mode(s) of its own: pass " + "modes=() beside provider=, or the guide's own " + "provider.mode_collection.modes" + ) + raise DriverError(msg) + planes = _save_planes(length, save_planes, n_save) + refuse_beside(launch, channel=channel, incident=incident, coupling=coupling) + pulse = _general_pulse( + incident, + channel, + shape, + wavelength=wavelength, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phases=phases, + pulses=pulses, + ) + built = (pulse.temporal if isinstance(pulse, SpatialPulse) else pulse).build( + working + ) + try: + route = general_modal_route( + provider, + working, + z_max=float(np.max(planes)), + omega_reference=float(working.omega0), + kerr_form=kerr_form, + selection=MaterialSelection() if materials is None else materials, + # The pulse is built first so that the route's occupied-support basis + # gate is weighted by where this run's light actually is rather than + # over the whole declared band. The launch's channel distribution does + # not enter that weight -- it is summed over channels -- so the pulse + # alone is enough, which is what lets a projected launch, which needs + # the route's own dual, still be weighted by its own spectrum. It + # reaches no leaf. + launch=built, + ) + except TranslationError as error: + # The bridge belongs to the translation layer and says so; a caller of + # `from_waveguide` is told what this entry point promises, and + # `DriverError` is what its `Raises` names. The message is the bridge's + # own, unchanged, and the cause is kept. + raise DriverError(str(error)) from error + beam = pulse if isinstance(pulse, SpatialPulse) else None + if (beam is None) != (coupling is None): + msg = ( + "an incident beam is coupled under a declared model: give incident= " + "with coupling=CouplingModel('vector_paraxial', model_error=...), or " + "neither" + ) + raise DriverError(msg) + launched: dict[str, object] | None = None + if beam is not None: + # Checked before the port is built: a guide whose first region is off the + # axis has no core for a beam to enter through. + _core_index(provider.regions, working) + # The launch is the coupling's, installed through `with_input` below. + field0 = np.zeros((len(channels), 1, working.n_omega), dtype=np.complex128) + elif launch is None: + field0 = _in_channel(built, provider, channel) + else: + # A declared change of basis carries a dense flux metric, under which the + # shares are a coordinate split; in the provider's own coordinates the + # metric is the identity and they are energy fractions (SEP 41, S5). + field0, launched = launch_pulse( + # No beam, so the input is the shaped pulse. + cast("Pulse", pulse), + launch, + working, + provider.mode_collection.channel_labels, + metric=metric_at(route.metric_tables, 0.0), + ) + boundary, scheme = _boundary(absorber, absorber_scheme, working) + controller, default = _keyword_controller("waveguide", rtol, atol) + settings = SimulationSettings( + controller=controller, + _default_controller=default, + _route="waveguide", + save_planes=planes, + dz_max=length / 2 if dz_max is None else dz_max, + dz_init=length * 1e-4 if dz_init is None else dz_init, + auto_init=dz_init is None, + schedule=schedule, + integrator=integrator, + interpolate_planes=interpolate_planes, + step_planes=0 if step_planes is None else step_planes, + absorber=boundary, + absorber_scheme=scheme, + boundary_check=boundary_check, + # The route carries its nodes, its materials and its normalisation in + # its own leaves: the static transverse quadrature, the component + # selection, the density path and the index override are all refused + # by the translation layer and are not settings of this run. + stats=_general_stats() if stats is None else stats, + polarisation_taper=polarisation_taper, + backend=backend, + ) + noise_model, resolution = resolve_noise("waveguide", noise, seed) + draw = functools.partial(_general_noise, noise_model, working, guide=provider) + simulation = cls( + working, + channels, + [], + field0, + settings=settings, + noise=draw(seed), + noise_resolution=resolution, + configuration={ + "waveguide": { + "modes": [ModeRecord.from_mode(mode) for mode in channels], + "length": length, + "provider": dict(provider.identity), + }, + **({} if beam is not None else {"pulse": pulse}), + **({} if launched is None else {"modal_launch": launched}), + }, + general=route, + guide=provider, + materials=materials, + ) + _redrawable(simulation, draw, seed) + if beam is None: + return simulation + return _incident_launch(simulation, beam, cast("CouplingModel", coupling)) + + +_GAS_KEYWORD_DEFAULTS: Mapping[str, object] = { + "gas": None, + "pressure": None, + "pressure_floor": None, + "vacuum_end": None, + "susceptibility": None, + "temperature": None, + "kerr": True, + "thg": None, + "plasma": None, + "preionisation": None, + "plasma_integration": None, + "raman": None, + "mode_average": False, + "n_r": None, + "n_theta": None, + "radial_rule": "gauss_kronrod", + "ionisation_field_range": None, + "polarisation": None, + "envelope": False, + "node_budget": None, + "taper_coupling": True, +} +"""Every gas-filled `from_waveguide` keyword, with the value that means "not given". + +A general modal guide owns its materials, its responses, its transverse rule and +its grid kind, so each of these is refused by name when it departs from the value +here; a keyword silently ignored would let a caller believe a fill, a response or +a resolution was applied. +""" + +_GAS_KEYWORD_REASONS: Mapping[str, str] = { + "gas": "a guide's materials are its regions' own laws, not a fill", + "pressure": "a guide's materials are its regions' own laws, not a fill", + "pressure_floor": "a guide has no fill and so no pressure path", + "vacuum_end": "a guide has no fill and so no gas flow to choke", + "susceptibility": "a guide's dispersion is its provider's, solved from the " + "regions' permittivity laws", + "temperature": "a material region's law is a declared state, and a guide " + "carries no gas whose density a temperature would set", + "kerr": "a guide's Kerr response is its regions'; declare a region with no " + "MaterialKerr to make it linear", + "thg": "the general route's third harmonic is set by kerr_form=: the real " + "cubic carries it and the analytic-signal form does not", + "plasma": "a general guide carries the instantaneous Kerr term alone; " + "ionisation is not implemented on the general route", + "preionisation": "a general guide carries no plasma channel", + "plasma_integration": "a general guide carries no plasma column to integrate", + "raman": "a guide's delayed response is its material regions' own, the silica " + "tensor Raman model", + "mode_average": "a general guide's channels are resolved on its own node rule; " + "mode averaging is the gas-filled route's", + "n_r": "the transverse rule is the guide's own node rule, declared when the " + "guide was built", + "n_theta": "the transverse rule is the guide's own node rule, declared when " + "the guide was built", + "radial_rule": "the transverse rule is the guide's own node rule, declared when " + "the guide was built", + "taper_coupling": "a guide that moves along z carries its own connection in the " + "general route's flow; the opt-out is the capillary route's", + "ionisation_field_range": "a general guide carries no ionisation table", + "polarisation": "a general guide puts every physical channel, including each " + "parity partner, in its own row; launch into them by name with channel=, or " + "build the launch yourself and pass it to Simulation.with_input", + "envelope": "the general route synthesises an analytic field from a " + "carrier-resolved spectrum and propagates on a field grid", + "node_budget": "the general route sizes its node chunk from its own memory " + "ledger, which counts different buffers", +} +"""Why each gas-filled keyword cannot be carried by a general modal guide.""" + + +def _means_not_given(value: object, default: object) -> bool: + """Return whether a supplied keyword carries the value that means "not given". + + - Returns: True when the keyword was, in effect, not given. + """ + if value is default: + return True + if isinstance(value, np.ndarray) and value.ndim > 0: + # `array == default` is elementwise and its truth value raises; a keyword + # whose "not given" value is a scalar or None is never an array anyway, so + # an array is always something the caller meant. + return False + return bool(value == default) + + +def _refuse_gas_keywords(supplied: Mapping[str, object]) -> None: + """Refuse, by name, every gas-filled keyword given beside a provider.""" + for name, value in supplied.items(): + # Indexed, not `.get`: a keyword this table does not know is a keyword + # `from_waveguide` would silently ignore on this route, and + # `test_from_waveguide_refuses_or_names_every_keyword_it_takes` locks the + # two together so that adding one cannot pass unnoticed. + default = _GAS_KEYWORD_DEFAULTS[name] + if _means_not_given(value, default): + continue + msg = ( + f"{name}={value!r} beside a provider=: {_GAS_KEYWORD_REASONS[name]}. " + "The general modal route takes its physics from the guide " + "(docs/guide/waveguides/general-modal.md)" + ) + raise DriverError(msg) + + +def _general_stats() -> StatsSpec: + """Return the statistics a general modal run records by default. + + The route's **own** names, not the legacy ones: ``physical_energy`` is the + metric-weighted energy through the plane and ``analytic_flux`` the peak total + analytic Poynting flux, both scalars, because a coordinate energy and a + coordinate power are not additive on a basis that is not orthonormal + (``docs/guide/results/statistics.md``). ``energy``, ``peak_power``, + ``peak_power_total`` and ``peak_intensity`` are refused by name on this basis + rather than redefined. ``omega0``, + ``fwhm_t`` and ``fwhm_t_outer`` stay: they are functionals of the + same-frequency flux ``a.H W a`` and take the basis metric + (:data:`solprop.stats.METRIC_WEIGHTED_STATISTICS`), so they are exact on a + declared basis as well as on the identity one. + ``quadrature_error`` is not here either: a provider's node rule has + no embedded coarse pair to difference. + """ + return StatsSpec( + names=("omega0", "physical_energy", "analytic_flux", "fwhm_t", "fwhm_t_outer") + ) + + +def _in_channel( + built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], + guide: ModalGuide, + channel: int | str | None, +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Place a one-channel pulse in a named or indexed channel of a guide. + + **The amplitude is placed unchanged.** A provider normalises every channel to + unit longitudinal Poynting flux, so a channel amplitude is already a true-power + amplitude and the launched energy is the pulse's; there is no ``sqrt(Re n_eff)`` + boundary on this route and nothing here crosses one. + + - ``channel``: row index, display label, or ``None`` for the first channel. + """ + labels = [label.display_label for label in guide.mode_collection.channel_labels] + if built.shape[0] != 1: + msg = ( + f"a general modal launch places one pulse in one channel and this " + f"input carries {built.shape[0]}; build the state yourself and pass it " + "to Simulation.with_input to launch into several at once" + ) + raise DriverError(msg) + if channel is None: + index = 0 + elif isinstance(channel, str): + if channel not in labels: + msg = f"channel={channel!r} is not one of this guide's: {labels}" + raise DriverError(msg) + index = labels.index(channel) + else: + index = int(channel) + if not 0 <= index < len(labels): + msg = ( + f"channel={channel!r} is outside this guide's {len(labels)} " + f"channel(s): {labels}" + ) + raise DriverError(msg) + state = np.zeros((len(labels), *built.shape[1:]), dtype=np.complex128) + state[index] = built[0] + logger.debug("general modal launch into channel %d (%s)", index, labels[index]) + return state + + +def _core_index( + regions: Sequence[MaterialRegion], grid: TimeGrid +) -> Float[np.ndarray, " n_omega"]: + """Return the real phase index of a guide's core at each of a grid's frequencies. + + The incident medium of a beam launched into the guide: the innermost material + region, whose law does not move with z, so it is the entrance's. + + - Returns: ``Re sqrt(eps)`` on the active positive band and zero elsewhere, shape + ``(n_omega,)``. + """ + core = regions[0] + if not isinstance(core.inner_radius, float | int) or core.inner_radius != 0: + msg = ( + f"an incident beam enters a guide through its core, the region on the " + f"axis, and this guide's first region {core.name!r} does not start there" + ) + raise DriverError(msg) + live = grid.active_mask & (grid.omega > 0) + index = np.zeros(grid.n_omega) + index[live] = np.real(np.sqrt(core.material.permittivity(grid.omega[live]))) + return index diff --git a/src/solprop/driver/routes/gnlse.py b/src/solprop/driver/routes/gnlse.py new file mode 100644 index 00000000..6d839c90 --- /dev/null +++ b/src/solprop/driver/routes/gnlse.py @@ -0,0 +1,227 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The GNLSE route: its grid, its fibre and its dispersion table.""" + +import dataclasses +import math +from collections.abc import Sequence + +import numpy as np +from jaxtyping import Float + +from solprop.constants import C +from solprop.driver.routes.keywords import _GRID_KEYWORDS, _about, _unset +from solprop.exceptions import DriverError +from solprop.grid import EnvelopeGrid, TimeGrid +from solprop.modes.protocol import Mode +from solprop.modes.tabulated import IndexTable +from solprop.nonlinear.gnlse import ( + GnlseProblem, + SpecifiedMode, + attenuation_from_db, + problem_from, +) +from solprop.physdata.selection import MaterialSelection + +__all__ = [] + + +def _gnlse_grid( + grid: TimeGrid | None, + length: float, + wavelength: float, + wavelength_limits: tuple[float, float] | None, + trange: float | None, +) -> TimeGrid: + """Return the envelope grid a GNLSE run propagates on. + + - ``grid``: a grid given by the caller, or ``None`` to build one. + - ``length``: fibre length, m. + - ``wavelength``: reference wavelength, m. + - ``wavelength_limits``: window limits, m. + - ``trange``: time window, s. + """ + if grid is not None: + if grid.kind != "envelope": + msg = ( + f"prop_gnlse propagates an envelope grid and this one is a " + f"{grid.kind} grid; the GNLSE mapping is the envelope Kerr response " + "(docs/guide/waveguides/gnlse.md), and a field-resolved run is " + "prop_capillary's " + "path" + ) + raise DriverError(msg, parameter="grid", rule="gnlse-envelope-grid") + return grid + if wavelength_limits is None or trange is None: + msg = ( + "give wavelength_limits and trange so a grid can be built, or a grid of " + "your own" + ) + raise DriverError( + msg, + parameter=_unset(wavelength_limits=wavelength_limits, trange=trange), + rule="grid-or-limits", + ) + with _about(renamed=_GRID_KEYWORDS): + return EnvelopeGrid.from_limits(length, wavelength, wavelength_limits, trange) + + +def _gnlse_fibre( + gamma: float | None, + betas: Sequence[float] | np.ndarray | None, + beta: object, + grid: TimeGrid, + *, + wavelength: float, + loss: float | None, + shock: bool, + fibre: Mode | IndexTable | None = None, + n2: float | str | None = None, + raman: bool = False, + raman_fraction: float | None = None, + selection: MaterialSelection | None = None, +) -> GnlseProblem: + """Map one dispersion source and one nonlinear coefficient onto the GNLSE. + + - ``gamma``: nonlinear coefficient in 1/(W m), absent beside `n2`. + - ``betas``: Taylor coefficients about the reference frequency, s^n/m. + - ``beta``: a pair of frequency and beta arrays, a callable, or None. + - ``wavelength``: positive reference wavelength, m. + - ``loss``: power loss in dB/m; None means omitted. Refused beside `fibre`. + - ``fibre``: whole dispersion and loss, including a positive propagation area for + `n2`. + - ``n2``: nonlinear index in m^2/W, or ``"recommended"`` under a `selection`; + requiring `fibre`. + - ``raman_fraction``: share of the nonlinearity held back from the Kerr term, + between zero and one; None resolves as in + :func:`solprop.nonlinear.gnlse.problem_from`. + """ + if sum(value is not None for value in (betas, beta, fibre)) != 1: + raise DriverError( + "give exactly one of betas, beta and fibre", + parameter=("betas", "beta", "fibre"), + rule="betas-beta-or-fibre", + ) + if (gamma is None) == (n2 is None): + raise DriverError( + "give exactly one of gamma and n2", + parameter=("gamma", "n2"), + rule="gamma-or-n2", + ) + if n2 is not None and fibre is None: + raise DriverError( + "n2 requires fibre with an effective area", + parameter=("n2", "fibre"), + rule="n2-needs-fibre-area", + ) + if fibre is not None and loss is not None: + raise DriverError( + "the fibre carries its loss; scale it on the mode", + parameter=("fibre", "loss_db_per_m"), + rule="fibre-carries-loss", + ) + omega_ref = 2.0 * np.pi * C / wavelength + mode = ( + None + if fibre is None + else _gnlse_mode(fibre, wavelength, needs_area=n2 is not None) + ) + area = 1.0 if mode is None else mode.aeff() + # The GNLSE mapping of ADR-026 (docs/dev/decisions.md) is unchanged. An externally + # specified fibre replaces the placeholder mode, so its dispersion never enters the + # unit prefactor. Its own names are the keywords' but for the area, the fibre's. + with _about(renamed={"aeff": "fibre"}): + mapping = problem_from( + np.asarray([0.0] if betas is None else betas, dtype=np.float64), + omega_ref=omega_ref, + gamma=gamma, + n2=n2, + aeff=area, + loss_db_per_m=0.0 if loss is None else loss, + shock=shock, + raman=raman, + raman_fraction=raman_fraction, + selection=selection, + ) + if mode is not None: + return dataclasses.replace(mapping, mode=mode) + if betas is not None: + return mapping + nodes, values = _dispersion_table(beta, grid) + with _about("beta"): + table = SpecifiedMode.from_table( + nodes, + values, + loss=attenuation_from_db(0.0 if loss is None else loss), + aeff=mapping.aeff, + ) + return dataclasses.replace(mapping, mode=table) + + +def _gnlse_mode( + fibre: Mode | IndexTable, wavelength: float, *, needs_area: bool +) -> Mode: + """Resolve a supplied table or mode with its propagation area at the pump. + + - ``wavelength``: positive reference wavelength, m: where a table's area column and + a provider channel's surrogate area are evaluated. + """ + if isinstance(fibre, IndexTable): + if needs_area and fibre.area is None: + raise DriverError( + "n2 requires a fibre table with an effective-area column", + parameter=("n2", "fibre"), + rule="n2-needs-fibre-area", + ) + area = ( + 1.0 + if fibre.area is None + else float( + fibre.effective_area(np.array([2.0 * np.pi * C / wavelength]))[0] + ) + ) + with _about("fibre"): + return SpecifiedMode.from_index_table(fibre, aeff=area) + if not math.isfinite(fibre.aeff(0.0)) or fibre.aeff(0.0) <= 0: + raise DriverError( + "fibre effective area must be positive and finite", parameter="fibre" + ) + # aeff=None hands over the fibre's own declared area path when it has one and + # samples the source's own area along z when its whole geometry moves; the + # frozen callables forward the position, so the dispersion and the area move + # together and a fibre moving along z needs no refusal (ADR-026, + # docs/dev/decisions.md). The wavelength is where a provider channel's area is + # solved, as a table's is read at it above; every other mode's area is not a + # function of frequency and ignores it. + with _about("fibre"): + return SpecifiedMode.from_mode(fibre, wavelength=wavelength) + + +def _dispersion_table( + beta: object, grid: TimeGrid +) -> tuple[Float[np.ndarray, " n_nodes"], Float[np.ndarray, " n_beta_nodes"]]: + """Return the ``(omega, beta)`` table a caller's dispersion describes. + + A callable is sampled on the grid's **active band**, sorted and made unique, because + that is exactly where the linear operator evaluates a mode + (:func:`solprop.linear.dispersion_operator`) and a spline refuses to extrapolate + beyond its data. + + - Returns: the frequencies in rad/s and the propagation constant in 1/m. + """ + if callable(beta): + nodes = np.unique(grid.omega[grid.active_mask]) + return nodes, np.asarray(beta(nodes), dtype=np.float64) + if isinstance(beta, tuple) and len(beta) == 2: + nodes, values = beta + return ( + np.asarray(nodes, dtype=np.float64), + np.asarray(values, dtype=np.float64), + ) + msg = ( + f"beta must be a (omega, beta) pair of arrays or a callable of omega, got " + f"{type(beta).__name__}" + ) + raise DriverError(msg, parameter="beta") diff --git a/src/solprop/driver/routes/hollow.py b/src/solprop/driver/routes/hollow.py new file mode 100644 index 00000000..346b52d2 --- /dev/null +++ b/src/solprop/driver/routes/hollow.py @@ -0,0 +1,1714 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The hollow-guide route: capillaries and supplied gas-filled mode sets. + +The grid, modes, transverse quadrature, responses and statistics of a gas-filled hollow +guide, the checks of its walls, gas flow and plasma settings, and the route itself. +""" + +import dataclasses +import functools +import logging +import warnings +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Final, Literal, cast + +import numpy as np +from scipy.special import j0, jn_zeros + +from solprop.boundaries import Absorber, warn_walkoff +from solprop.driver.defaults import WAVEGUIDE_ATOL, resolve_noise +from solprop.driver.inputs import ( + ModalLaunch, + _hollow_input, + _InputPeak, + _ionisation_inputs, + _redrawable, + _shaped_pulse, + _shot_noise, +) +from solprop.driver.mixtures import ( + ResponseSelection, + capillary_fill, + is_air, + mixture_profiles, +) +from solprop.driver.routes.keywords import ( + _GRID_KEYWORDS, + _MATERIAL_WARNINGS, + _about, + _unset, + _warnings_about, +) +from solprop.driver.routes.materials import _check_selection_carrier, _default_cladding +from solprop.driver.settings import ( + NoiseSetting, + SimulationSettings, + _boundary, + _keyword_controller, + _save_planes, +) +from solprop.driver.tdse_run import _entry_overrides, _tdse_entry, _tdse_stats +from solprop.exceptions import ( + DriverError, + GasFlowWarning, + SampledPeakWarning, + SolpropWarning, +) +from solprop.gasflow import VacuumEnd +from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid +from solprop.linear import Frame, frame_quantities +from solprop.modes.antiresonant import TubeLattice +from solprop.modes.capillary import ( + CoherentWall, + GlassIndex, + LossSpec, + MarcatiliMode, + MarcatiliModel, +) +from solprop.modes.collection import ModeCollection +from solprop.modes.protocol import Mode +from solprop.modes.quadrature import ( + RadialRule, + TransverseQuadrature, + cartesian_nodes, + transverse_quadrature, +) +from solprop.modes.slab import SlabMode +from solprop.modes.stepindex import StepIndexMode +from solprop.monitor import DEFAULT_BOUNDARY_CHECK, BoundaryCheck +from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse +from solprop.nonlinear.ionisation import IntegrationPolicy, IonisationRate +from solprop.nonlinear.mixtures import electronic_response, responses_for +from solprop.nonlinear.optmat_raman import mixture_raman, raman_status +from solprop.nonlinear.optmat_rates import selected_rate +from solprop.nonlinear.raman import MolecularOptions +from solprop.nonlinear.responses import ( + DispersiveKerrResponse, + KerrResponse, + PlasmaResponse, +) +from solprop.nonlinear.tdse import TDSEExecution, TDSEGasResponse +from solprop.output.schema import ModeRecord +from solprop.physdata import Gas, Mixture, species +from solprop.physdata.mixtures import Pressure +from solprop.physdata.selection import MaterialSelection +from solprop.physdata.sellmeier import GasSusceptibility +from solprop.physdata.species import Species, built_in_population +from solprop.profiles import PressureGradient, ZProfile, as_profile +from solprop.pulses import Pulse +from solprop.stats import PLASMA_INTEGRATION_STATISTICS, StatsSpec, default_spec +from solprop.stepper.low_storage import LowStorageRungeKutta +from solprop.stepper.multistep import Multistep +from solprop.stepper.runge_kutta import RungeKutta +from solprop.stepper.schedule import FixedSchedule +from solprop.translate.masks import AbsorberScheme +from solprop.translate.problem import DEFAULT_COMPONENTS + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [ + "BACKEND_STATISTICS", + "ORTHONORMALITY_TOLERANCE", + "PLASMA_STATISTICS", + "default_radial_order", +] + +logger = logging.getLogger(__name__) + +PLASMA_STATISTICS = ("electron_density", "peak_ionisation_rate") +"""Backend statistics that read the plasma the run compiled; refused without one.""" + +BACKEND_STATISTICS = ("peak_intensity", "quadrature_error", *PLASMA_STATISTICS) +"""The statistics only a backend can compute, recorded by a capillary run by default. + +Each needs something the kernel contract does not carry to a later pass: the quadrature +nodes the plasma was evaluated at, the mode set the axis needs, or the embedded +coarse-rule projection, which is gone once the step is accepted. The two plasma entries +are recorded only by a run that has a plasma response; the backend refuses them +otherwise, and a default that always asked for them would make ``plasma=False`` fail. +""" + +ORTHONORMALITY_TOLERANCE = 1e-6 +"""How far a given mode set may stray from orthonormality before it is refused. + +The transform is one synthesis matrix and its dual, which represent an orthonormal +basis and nothing else, so a set that is not one is propagating a different problem +from the one it describes. The bound is loose because the overlaps are integrated +adaptively by :func:`scipy.integrate.dblquad`, whose own accuracy on a set of Bessel +profiles is around 1e-9. +""" + +_MINIMUM_RADIAL_ORDER = 32 +"""Floor on the default radial order; see :func:`default_radial_order`.""" + +_RADIAL_NODES_PER_MODE = 8 +"""Radial nodes per mode in the default order; see :func:`default_radial_order`.""" + + +def _from_hollow( # noqa: PLR0915 - the interface's keyword surface + cls: type["Simulation"], + radius: float | ZProfile | None, + length: float, + gas: Gas | str | Mapping[str, float] | Mixture, + pressure: Pressure | None = None, + *, + waveguide: bool = False, + pressure_floor: float | None = None, + vacuum_end: VacuumEnd | None = None, + wavelength: float, + wavelength_limits: tuple[float, float] | None = None, + trange: float | None = None, + grid: TimeGrid | None = None, + envelope: bool = False, + fwhm: float | None = None, + width: float | None = None, + shape: str = "gauss", + energy: float | None = None, + peak_power: float | None = None, + phases: Sequence[float] | np.ndarray = (), + pulses: Sequence[Pulse] | None = None, + launch: ModalLaunch | None = None, + modes: int | Sequence[Mode] = 1, + mode_average: bool = False, + lattice: TubeLattice | None = None, + wall: CoherentWall | None = None, + taper_coupling: bool = True, + model: MarcatiliModel = "full", + loss: LossSpec = True, + cladding: GlassIndex | complex | None = None, + susceptibility: GasSusceptibility | None = None, + temperature: float | ZProfile | None = None, + kerr: bool = True, + thg: bool | None = None, + plasma: bool | IonisationRate | Mapping[str, bool | IonisationRate] | None = None, + preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None, + plasma_integration: IntegrationPolicy | None = None, + raman: bool | Sequence[str] | Mapping[str, bool | MolecularOptions] | None = None, + n_r: int | None = None, + n_theta: int | None = None, + radial_rule: RadialRule = "gauss_kronrod", + ionisation_field_range: tuple[float, float] | None = None, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + noise: NoiseSetting = "default", + seed: int | None = None, + rtol: float | None = None, + atol: float = WAVEGUIDE_ATOL, + dz_max: float | None = None, + dz_init: float | None = None, + schedule: FixedSchedule | None = None, + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, + route: Literal["capillary", "waveguide"] = "capillary", + interpolate_planes: bool = False, + step_planes: int | None = None, + save_planes: Sequence[float] | np.ndarray | None = None, + n_save: int | None = None, + stats: StatsSpec | None = None, + polarisation_taper: bool = False, + node_budget: int | None = None, + backend: str = "auto", + polarisation: str | float | None = None, + materials: MaterialSelection | None = None, + tdse: TDSEGasResponse | None = None, + tdse_execution: TDSEExecution | None = None, +) -> "Simulation": + """Build a simulation of a pulse in a gas-filled hollow waveguide. + + The shared body of :meth:`from_capillary` and :meth:`from_waveguide`. The mode + set is the first `modes` HE_1m modes of the capillary unless a set is given; the + transverse integral is a fixed quadrature rule + (``docs/guide/waveguides/mode-sets.md``); the + gas density along z is the pressure path through the equation of state, and the + same path sets the modes' core index, so a gradient fill disperses and broadens + consistently. + + See ``docs/guide/setup/running.md`` for the discussion and a worked example. + + - ``radius``: core radius, m, constant or along z. + - ``length``: capillary length, m. + - ``gas``: Built-in gas, mole fractions keyed by species name, or ordered + populations carrying their own density paths. A mapping uses total pressure. + - ``pressure``: None uses one bar for a gas or fractions. Omit pressure beside a + Mixture; every explicit pressure is refused because it already carries its + paths. Fill pressure in **bar**: a number for a constant fill, a pair ``(p_in, + p_out)`` for the steady-flow gradient of + :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the + positions and the pressures there for + :meth:`solprop.profiles.PressureGradient.from_stages`. + - ``pressure_floor``: lowest pressure the fill is allowed to reach, bar; + ``None`` takes :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the + highest stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true + vacuum end, which warns, because ``dp/dz`` diverges there. Refused beside a + constant pressure or a pressure profile, which have no end to apply it to. + - ``vacuum_end``: how a pressure gradient's outer end below its choked pressure + is treated. ``None`` (the default) means ``"choked"`` for a circular hollow + guide: the fill follows the bore (Pennetta et al. 2019, eq. 6) and an end + exhausting into vacuum takes the choked pressure of the flow + (:func:`solprop.gasflow.steady_fill`), with a warning if the flow is not + laminar. ``"floor"`` concerns the ends only: it keeps the declared end + pressures, raised only to `pressure_floor`, for a comparison with a model that + imposes them, while a taper's fill still follows the bore (on a uniform bore, + bitwise the uniform law). Refused beside a constant pressure, a pressure + profile or a Mixture, which have no end to choke, and on the general route. + Every mode declaring ``core_radius`` -- every ``MarcatiliMode`` subclass, a + ``TabulatedMode`` built from arrays included -- is taken as a circular bore of + that radius; a rectangular guide keeps the uniform law. + - ``wavelength``: reference wavelength, m, and the pulse's centre wavelength. + - ``wavelength_limits``: shortest and longest wavelength of the frequency + window, m. Required unless `grid` is given. + - ``trange``: requested width of the time window, s. Required unless `grid` is + given. + - ``grid``: a grid of one's own, instead of the three keywords above; the only + way to reach ``dt``, ``ffac`` or the transform size. + - ``fwhm``: pulse duration at half maximum, s. + - ``width``: natural width of a sech pulse, s; give this or `fwhm`. + - ``shape``: Transform-limited shape of the input. + - ``energy``: pulse energy, J. Give this or `peak_power`. + - ``peak_power``: peak power, W, realised after the spectral phases. + - ``phases``: Taylor spectral phases about `wavelength`, optics convention: CEP + in rad, group delay in s, GDD in s^2, and so on. + - ``pulses``: several pulses launched together, for two-colour and multi-colour + inputs: each a :mod:`solprop.pulses` object with its own wavelength, duration, + energy or peak power, phase and delay; the input is the sum of their fields + (:class:`~solprop.pulses.PulseSum`). Replaces the single-pulse keywords + `fwhm`, `width`, `shape`, `energy`, `peak_power` and `phases`, which must then + be left unset; `wavelength` still sets the grid's reference wavelength. + - ``launch``: the modes each pulse enters, with their shares and phases, as + :meth:`from_capillary` documents it; ``None`` puts the input in the first + mode. + - ``mode_average``: propagate the one mode **mode-averaged**: a uniform disc of + the mode's effective area instead of a transverse rule + (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the + area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 + times ``P/A_eff``), while ``peak_intensity`` stays on the axis. ``False``, the + default, resolves the mode on the radial rule. Refused beside more than one + mode and beside `n_r`, `n_theta` or a `radial_rule`. + - ``lattice``: tube geometry for an integer mode count, selecting Vincetti + modes. Refused with supplied modes, which carry their own geometry, and with + ``model="reduced"``. None selects ordinary capillary modes. + - ``wall``: declare the scalar **coherent** wall block: the outgoing Robin + boundary condition condensed onto the retained basis, which couples every + ``HE_1m`` channel to every other with a definite phase instead of giving each + its own Marcatili attenuation. It needs the contiguous set ``HE_11`` to + ``HE_1N`` -- or, with ``impedance="te_tm"``, one azimuthal order -- on a + constant or a varying guide, and a scalar run; it carries the default + ``"input"`` noise and refuses ``"modified"``. ``numpy_ref`` and ``native`` + execute it; every other mode set or geometry is refused by name. ``None``, + the default, carries no wall: the modes keep their own Marcatili loss, and a + multimode taper its taper coupling (below). + - ``taper_coupling``: on a multimode capillary whose radius varies along z, + carry the **taper coupling** -- the connection ```` that + moves power between the ``HE_1m`` channels as the bore changes -- lossless or + not. ``True``, the default; ``False`` is the explicit opt-out to the + **adiabatic** route, on which each channel follows its local mode and + exchanges no power. It is refused beside `wall`, which always carries the + connection, and has no effect on one channel, a constant bore (a pressure + gradient included, which does not change the modes' shape) or a set that is + not the contiguous ``HE_11`` to ``HE_1N`` block + (``docs/guide/waveguides/capillaries.md``, "Tapers"). + - ``model``: which Marcatili model the modes use; beside a supplied set, refused + unless it is the default or the modes' own. + - ``loss``: propagation loss of the modes; beside a supplied set, refused unless + it is the default or the modes' own. + - ``cladding``: cladding index; ``None`` is the package's cited silica. Beside a + supplied set, refused unless it is the default or the modes' own. + - ``temperature``: gas temperature, K, or a path of it along z in m. Sets the + density the pressure path maps to, so it changes both the dispersion and the + nonlinearity. A path also weights a steady-flow gradient by the gas's ``eta(T) + T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's populations + independent parameters, and holds the rotational Raman populations at one + reference temperature with a + :class:`~solprop.exceptions.RamanTemperatureWarning` + (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path is the + number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture owns its + temperature: beside one, a temperature must be omitted or be the mixture's own + (the same number or path object), and any other is refused by name. + - ``thg``: include third-harmonic generation in the Kerr term; ``None`` takes + ``not envelope``. + - ``plasma``: include photoionisation. ``None`` takes ``not envelope``. ``True`` + selects optmat's recommended rate model of each built-in gas (SEP 3, D1; + :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the + selection's ``ionisation`` names), for a single gas and for every component of + a mixture; a custom species needs its own rate. A rate object is used as + given; a mapping selects a rate or switch per component label, and omitted + labels are off. + - ``preionisation``: initial ionised fraction in [0, 1], globally or per label: + a number, or a path along z in m giving the fraction ionised before the pulse + arrives at each position. None and omitted mapping labels retain the + component's existing declaration. + - ``plasma_integration``: how the field-grid plasma forms its population and + free-current integrals (SEP 18): ``'band'`` filters each integrand with taps + designed for the grid, exact to a certified 1e-5 across the active band; + ``'trapezoid'`` is the plain cumulative trapezoid, short by up to 11 % per + integral at the top of the default active band, which the frozen witness + comparisons use. ``None`` takes + :class:`~solprop.nonlinear.responses.PlasmaResponse`'s default. Refused with + envelope plasma, whose currents are converted exactly, and when the plasma is + off. + - ``raman``: ``None`` (the default) turns the Raman response on for every + species whose optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- + leaves an atom without one, and warns + (:class:`solprop.exceptions.SolpropWarning`) for a molecule whose response is + provisional (N2O, unless the selection's ``raman`` is ``"provisional"``) + rather than leaving it out silently; with ``kerr=False`` the default is off. + ``False`` disables it; ``True`` selects the available models of a mixture and + is refused by name for a single gas without data; explicit labels or + :class:`~solprop.physdata.raman_types.MolecularOptions` select components and + terms, and a named unavailable model fails explicitly. + - ``n_r``: radial nodes of the fixed rule. ``None`` takes + :func:`default_radial_order` of the mode count. + - ``ionisation_field_range``: field amplitudes in V/m the rate table must cover. + ``None`` derives them from the input's peak field over the axis and the + transverse nodes; see :func:`solprop.driver.inputs.default_field_range`. + - ``absorber``: the collar rates; ``None`` builds the default collar of the + grid, and ``False`` switches both soft collars off and keeps the hard band + mask (:func:`solprop.boundaries.no_absorber`; SEP 21). + - ``boundary_check``: how the run watches its time window's edges (SEP 21): the + default warns, then stops, when light above the shot-noise floor reaches them; + ``None`` watches nothing, for a window kept narrow on purpose. + - ``rtol``: relative step tolerance; ``None`` takes the route's default + (:func:`~solprop.driver.defaults.default_rtol`). Calibrate it for a production + run (``docs/dev/stepper.md`` section 3). + - ``dz_max``: cap on the step, m; ``None`` takes half the capillary. + - ``dz_init``: first step, m; ``None`` takes ``length * 1e-4``. + - ``integrator``: the method that steps the run + (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, takes + ``Multistep()``: the low-call exponential Adams method, one response + evaluation per step. ``RungeKutta()`` is the interaction-picture DOPRI5 march, + for a cheap response or a state too large for the Adams history. + ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). + - ``route``: the interface building the run, whose row of + :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` gives what the caller left: + ``"capillary"`` (its TDSE variant beside a TDSE response) or ``"waveguide"`` + (its slab variant for a slab mode set). + - ``step_planes``: also save the end of every `step_planes`-th accepted step + (SEP 14); ``None`` is ``0``, or ``1`` with `tdse` (SEP 20 section 5.8). + - ``save_planes``: positions to save at, m. Give this or `n_save`. + - ``stats``: which statistics to record; ``None`` takes + :func:`solprop.stats.default_spec` for a multimode run with the profiles, or + for a mode-averaged one under `mode_average`, plus every statistic only a + backend can compute. + - ``node_budget``: bytes of node-chunk buffers the response pass may hold, an + execution setting; ``None``, the default, leaves the backend's automatic + choice. + - ``backend``: registry name, or ``"auto"`` (the default) for ``native``, which + refuses if it isn't installed or this processor is below its baseline. There + is no fallback, and import or construction errors stay visible. + - ``materials``: take the run's built-in materials from optmat under this + selection: a gas or fraction mapping of built-in gases, ``"air"`` and + ``Gas.Air`` resolve through :func:`solprop.physdata.species` and + :meth:`solprop.physdata.mixtures.Mixture.air` with it, and the default + cladding (``cladding=None`` with a numbered mode set) is optmat's recommended + silica. A supplied mixture, mode set, cladding or susceptibility is taken as + it is. The run records its material manifest. ``None`` (the default) is + ``MaterialSelection()``. + """ + # The TDSE response owns the electronic response: build no other. + atomic, plasma, raman, backend, n_save, step_planes = _entry_overrides( + _tdse_entry( + tdse, + tdse_execution, + trange=trange, + kerr=kerr, + mode_average=mode_average, + unset={ + "plasma": plasma, + "raman": raman, + "preionisation": preionisation, + "plasma_integration": plasma_integration, + "ionisation_field_range": ionisation_field_range, + "thg": thg, + }, + backend=backend, + step_planes=step_planes, + save_planes=save_planes, + n_save=n_save, + ), + plasma, + raman, + backend, + n_save, + step_planes, + ) + if raman and not kerr: + msg = ( + "raman=True needs kerr=True: a medium with a Raman response has an " + "electronic one too, and on a field grid the drive form reaches the " + "kernel through the Kerr term (docs/guide/media/raman.md)" + ) + raise DriverError(msg, parameter=("raman", "kerr"), rule="raman-needs-kerr") + if polarisation is not None: + msg = ( + f"polarisation={polarisation!r}: this path is scalar and propagates " + "one transverse component per mode row. Declare the polarisation " + "in the modes instead -- a rectangular or slab guide's own " + "polarisation, each partner a row of its own -- or, for a vector " + "capillary run, a polarisation-resolved mode set, which is not " + "validated end to end yet" + ) + raise DriverError( + msg, parameter="polarisation", rule="scalar-capillary-polarisation" + ) + _check_wall_declaration( + wall, + model=model, + loss=loss, + lattice=lattice, + taper_coupling=taper_coupling, + ) + thg, plasma = ( + (not envelope) if thg is None else thg, + (not envelope) if plasma is None else plasma, + ) + active_plasma = ( + any(value is not False for value in plasma.values()) + if isinstance(plasma, Mapping) + else plasma is not False + ) + _refuse_plasma_settings( + plasma_integration, envelope=envelope, active=active_plasma, thg=thg + ) + + working = _capillary_grid( + grid, + length, + wavelength, + wavelength_limits, + trange, + envelope=envelope, + thg=thg, + ) + materials = MaterialSelection() if materials is None else materials + _check_selection_carrier(materials, working.reference_wavelength, wavelength) + bore = ( + _flow_radius(modes, pressure) + if waveguide + else as_profile(cast("float | ZProfile", radius)) + ) + # The fill's own models warn about the conditions they are evaluated at, + # which the gas keyword chose. + with _warnings_about("gas", _MATERIAL_WARNINGS): + mixture = capillary_fill( + gas, + pressure, + length=length, + temperature=temperature, + floor=pressure_floor, + susceptibility=susceptibility, + preionisation=preionisation, + radius=bore, + vacuum_end=vacuum_end, + selection=materials, + ) + _warn_boreless_gradient(mixture, bore) + if waveguide: + if isinstance(modes, int): + raise DriverError( + "from_waveguide requires supplied gas-filled modes", + parameter="modes", + ) + mode_set = _given_gas_modes(modes, mixture) + else: + mode_set = _capillary_modes( + modes, + cast("float | ZProfile", radius), + mixture, + length=length, + lattice=lattice, + model=model, + loss=loss, + cladding=_default_cladding(cladding) + if isinstance(modes, int) + else cladding, + ) + rule = ( + _no_quadrature(mode_set, n_r, n_theta, radial_rule=radial_rule) + if mode_average + else _capillary_quadrature(mode_set, n_r, n_theta, radial_rule=radial_rule) + ) + profiles = mixture_profiles(mixture) + if waveguide: + for mode in mode_set: + paths: Mapping[str, ZProfile] = getattr(mode, "parameter_paths", {}) + profiles.update( + { + name: path + for name, path in paths.items() + if name not in profiles + and (not path.is_constant or name == "core_radius") + } + ) + else: + profiles["core_radius"] = as_profile(cast("float | ZProfile", radius)) + if raman is None: + # The default follows the Kerr switch: a run without the electronic + # response is a deliberately reduced model, and a field-grid Raman term + # cannot stand without a Kerr term beside it anyway. + raman = kerr and _default_raman( + mixture, provisional=materials.raman == "provisional" + ) + if atomic is not None: + responses = [atomic.response] + elif ( + isinstance(gas, (Gas, str)) + and not is_air(gas) + and isinstance(raman, bool) + and not isinstance(plasma, Mapping) + and not isinstance(preionisation, Mapping) + ): + # The Raman response is optmat's, built from the fill (SEP 3); the other + # responses keep this path. + with _warnings_about("gas", _MATERIAL_WARNINGS): + responses = _capillary_responses( + gas, + mixture.components[0].species, + kerr=kerr, + plasma=plasma, + preionisation=0.0 if preionisation is None else preionisation, + integration=plasma_integration, + wavelength=wavelength, + envelope=envelope, + # The single gas's electronic response is optmat's, at the + # carrier or dispersive, as the selection's electronic policy + # says (SEP 6). + electronic=electronic_response( + mixture.components[0].species, + selection=materials, + wavelength=wavelength, + thg=thg, + ) + if kerr + else None, + selection=materials, + ) + # optmat's Raman response is the raman keyword's choice. + with _about("raman"), _warnings_about("raman", _MATERIAL_WARNINGS): + responses += ( + _optmat_raman(gas, mixture, materials, thg, wavelength, length) + if raman + else [] + ) + else: + with _warnings_about("gas", _MATERIAL_WARNINGS): + responses = list( + responses_for( + mixture, + kerr=kerr, + thg=thg, + plasma=plasma, + raman=raman, + wavelength=wavelength, + envelope=envelope, + length=length, + selection=materials, + plasma_integration=plasma_integration, + ) + ) + with _about(("preionisation", "plasma")): + _refuse_idle_preionisation(mixture, responses) + pulse = _shaped_pulse( + shape, + wavelength=wavelength, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phases=phases, + pulses=pulses, + ) + field0, launched = _hollow_input(pulse, launch, working, mode_set.modes) + ionising = any( + isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) for one in responses + ) + boundary, scheme = _boundary(absorber, absorber_scheme, working) + # A hollow guide's default is the grid's collar, so there is always one. + _warn_capillary_walkoff(mode_set.modes, working, cast("Absorber", boundary)) + responses, field_range, reference = _ionisation_inputs( + responses, + ionisation_field_range, + _InputPeak(field0, mode_set, working, rule) if ionising else None, + ) + row = ( + "waveguide slab" + if any(isinstance(mode, SlabMode) for mode in mode_set.modes) + else "capillary tdse" + if atomic is not None + else route + ) + controller, default = _keyword_controller(row, rtol, atol) + settings = SimulationSettings( + controller=controller, + _default_controller=default, + _route=row, + save_planes=_save_planes(length, save_planes, n_save), + # Relative defaults, so that the first step and the cap scale with the + # capillary. + dz_max=length / 2 if dz_max is None else dz_max, + dz_init=length * 1e-4 if dz_init is None else dz_init, + auto_init=dz_init is None, + schedule=schedule, + integrator=integrator, + interpolate_planes=interpolate_planes, + step_planes=step_planes, + density=None, + absorber=boundary, + absorber_scheme=scheme, + boundary_check=boundary_check, + quadrature=rule, + components=None + if rule is None + else _hollow_components(supplied=not isinstance(modes, int)), + ionisation_field_range=field_range, + node_budget=node_budget, + stats=( + _with_integration_statistics( + _hollow_stats( + with_plasma=ionising, + mixture=mixture, + profiles=profiles, + rule=rule, + ) + if waveguide + else _capillary_stats( + with_plasma=ionising, mixture=mixture, averaged=rule is None + ), + responses, + ) + if stats is None + else stats + ) + if atomic is None or stats is not None + else _tdse_stats( + _capillary_stats(with_plasma=False, mixture=mixture, averaged=rule is None) + ), + polarisation_taper=polarisation_taper, + backend=backend, + tdse_execution=None if atomic is None else atomic.execution, + ) + noise_model, resolution = resolve_noise(row, noise, seed) + draw = functools.partial(_shot_noise, noise_model, working, n_modes=len(mode_set)) + simulation = cls( + working, + mode_set.modes, + responses, + field0, + settings=settings, + noise=draw(seed), + noise_resolution=resolution, + profiles=profiles, + wall=wall, + taper_coupling=taper_coupling, + materials=materials, + configuration={ + "medium": mixture, + **( + { + "waveguide": { + "modes": [ModeRecord.from_mode(mode) for mode in mode_set], + "length": length, + "temperature": mixture.temperature, + } + } + if waveguide + else { + "capillary": { + "radius": radius, + "length": length, + "temperature": mixture.temperature, + } + } + ), + "pulse": pulse, + **({} if launched is None else {"modal_launch": launched}), + **({} if atomic is None else {"tdse_entry": atomic.record}), + }, + ) + # A TDSE run keeps no selection: a mixture update must not rebuild the + # rate-based responses in its place. + simulation._mixture_selection = ( + None + if atomic is not None + else ResponseSelection( + kerr, + thg, + plasma, + raman, + wavelength, + envelope, + length, + selection=materials, + reference_field=reference, + field_range=field_range, + plasma_integration=plasma_integration, + ) + ) + return _redrawable(simulation, draw, seed) + + +def _refuse_plasma_settings( + integration: IntegrationPolicy | None, *, envelope: bool, active: bool, thg: bool +) -> None: + """Refuse plasma settings that cannot act together. + + Envelope plasma cannot carry the third harmonic, and a running-integral rule + belongs to the field-grid plasma column: given beside envelope plasma, or with + the plasma off, nothing would read it. + """ + if envelope and active and thg: + msg = "envelope plasma cannot be combined with THG" + raise DriverError( + msg, parameter=("envelope", "plasma", "thg"), rule="envelope-plasma-no-thg" + ) + if integration is None: + return + if envelope: + msg = ( + f"plasma_integration={integration!r} selects the field-grid plasma " + "column's running-integral rule; envelope plasma converts its currents " + "with exact inverse-frequency factors and takes no such rule" + ) + raise DriverError( + msg, + parameter=("plasma_integration", "envelope"), + rule="plasma-integration-field-grid", + ) + if not active: + msg = ( + f"plasma_integration={integration!r} is given but the plasma is off; " + "turn it on (plasma=True) or drop plasma_integration" + ) + raise DriverError( + msg, + parameter=("plasma_integration", "plasma"), + rule="plasma-integration-needs-plasma", + ) + + +def _refuse_idle_preionisation(mixture: Mixture, responses: Sequence[object]) -> None: + """Refuse a declared initial ionised fraction that no plasma channel carries. + + A component's preionisation reaches the run only through its plasma or + envelope-plasma response. Without one the declaration would be recorded in the + run's metadata and ignored by the propagation, so it is refused by name. + """ + carried = { + _population_label(one, mixture) + for one in responses + if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) + } + for component in mixture.components: + value = component.preionisation + declared = isinstance(value, ZProfile) or value != 0 + if declared and component.label not in carried: + msg = ( + f"preionisation is declared for {component.label!r}, which has no " + "plasma response, so no channel would carry it; turn its plasma on " + "(plasma=True or plasma={label: True}) or drop the preionisation" + ) + raise DriverError(msg) + + +def _population_label( + response: PlasmaResponse | EnvelopePlasmaResponse, mixture: Mixture +) -> str: + """Resolve an existing plasma declaration to its already-validated population.""" + if response.species is not None: + return response.species + if len(mixture.components) == 1: + return mixture.labels[0] + name = getattr(response.gas, "name", str(response.gas)) + return next( + component.label + for component in mixture.components + if component.species.name == name + ) + + +def _check_wall_declaration( + wall: CoherentWall | None, + *, + model: MarcatiliModel, + loss: LossSpec, + lattice: TubeLattice | None, + taper_coupling: bool = True, +) -> None: + """Refuse, at the keyword the caller typed, a wall this run cannot carry. + + The highest of the coherent wall's doors, and the only one that can name the + argument rather than the object it became: the backend selection, the loss + setting the block would replace, the capillary model it is not derived for and + the structured cladding it does not describe. Each is refused again further + down -- in :func:`solprop.modes.capillary.wall_operator` and at the backend -- + so nothing depends on this one being reached. + + - ``wall``: what was declared. ``None`` leaves every other argument alone. + """ + if wall is None: + return + if not taper_coupling: + msg = ( + "taper_coupling=False declines the taper connection, and a coherent wall " + "block always carries it: its basis is attached to the local radius. " + "Leave taper_coupling at its default with wall=, or pass wall=None for " + "the adiabatic route" + ) + raise DriverError( + msg, parameter=("wall", "taper_coupling"), rule="wall-keeps-taper-coupling" + ) + if loss is not True: + msg = ( + f"a coherent wall block is the loss model, and this run declares " + f"loss={loss!r}: the condensed Robin boundary condition replaces the " + "Marcatili attenuation of every channel, so a LossSpec beside it would " + "be discarded in silence. Pass loss=True with wall=, or leave wall=None " + "and keep the uncoupled loss you asked for" + ) + raise DriverError(msg, parameter=("wall", "loss"), rule="wall-is-the-loss") + if model != "full": + msg = ( + f"a coherent wall block needs one capillary model, the full one, and " + f"this run declares model={model!r}: the condensation inverts the full " + "model's transverse eigenvalue, and the reduced model's leading-order " + "carrier beside it would be overridden rather than propagated" + ) + raise DriverError(msg, parameter=("wall", "model"), rule="wall-full-model") + if lattice is not None: + msg = ( + "a coherent wall block assumes one uniform dielectric wall at a single " + "radius, and this run declares a tube lattice: an antiresonant guide's " + "loss is its tubes' resonances, not a Robin condition on a bare " + "capillary. Leave wall=None and keep the Vincetti loss, or drop the " + "lattice" + ) + raise DriverError(msg, parameter=("wall", "lattice"), rule="wall-no-lattice") + + +# Positions along the capillary at which the modes' group delays are read. +_WALKOFF_SAMPLES: Final = 33 + + +_PACKAGE_FILES: Final = (str(Path(__file__).resolve().parents[1]),) +"""Frames a fill warning skips, so that it names the caller's line, not solprop's.""" + + +def _flow_radius( + modes: int | Sequence[Mode], pressure: Pressure | None +) -> ZProfile | None: + """Return the core radius a supplied mode set's gas flows through, if circular. + + Every mode that declares ``core_radius`` -- every ``MarcatiliMode`` subclass: a + capillary, a Vincetti mode, a tabulated hollow mode and a ``TabulatedMode`` built + from arrays -- is taken as a circular bore of that declared radius, which sets the + Poiseuille conductance and the choke (for a tube lattice the gaps between the tubes + are ignored). A rectangular guide's duct resistance and a slab's flow between plates + are not modelled: they keep the uniform law, and warn if their widths or gap vary + under a pressure gradient. + + - Returns: the first mode's core radius in m, or ``None`` for a guide without one. + """ + if isinstance(modes, int) or not modes: + return None + first = modes[0] + radius = getattr(first, "core_radius", None) + if isinstance(radius, ZProfile): + return radius + widths = [getattr(first, name, None) for name in ("width_x", "width_y")] + varying = any(isinstance(w, ZProfile) and not w.is_constant for w in widths) + if varying and isinstance(pressure, Sequence): + # A slab declares only width_x, its half-gap: flow between plates goes as + # a^3 per unit width, which is not modelled either. + message = ( + "the slab's gap varies along z, and its pressure gradient follows the " + "uniform law: flow between plates is not modelled, so the fill ignores " + "the gap" + if widths[1] is None + else "the rectangular guide's widths vary along z, and its pressure " + "gradient follows the uniform law: a rectangular duct's hydraulic " + "resistance is not modelled, so the fill ignores the widths" + ) + warnings.warn( + message, + GasFlowWarning, + stacklevel=2, + skip_file_prefixes=_PACKAGE_FILES, + ) + return None + + +def _warn_boreless_gradient(mixture: Mixture, radius: ZProfile | None) -> None: + """Warn if a gradient built without the bore fills a guide whose radius varies. + + A :class:`~solprop.profiles.PressureGradient` supplied directly, or inside a + Mixture, was built by its caller; if it does not know the radius, its ``p^2`` is + linear in ``z`` although a taper's is linear in ``int a^-4 dz``. + + - ``radius``: the guide's core radius in m, if it has one. + """ + shared = mixture.shared + if ( + radius is None + or radius.is_constant + or not isinstance(shared, PressureGradient) + or shared.is_constant + or shared.radius is not None + ): + return + warnings.warn( + "the pressure gradient was built without the bore, and the guide's core " + "radius varies along z: its p^2 is linear in z, whereas a taper's steady flow " + "makes p^2 linear in the hydraulic resistance int a^-4 dz, so a narrow waist " + "carries most of the drop. Build it with PressureGradient.from_stages(..., " + "radius=) or solprop.gasflow.steady_fill, or pass the end pressures as a pair " + "and let the constructor build the fill", + GasFlowWarning, + stacklevel=2, + skip_file_prefixes=_PACKAGE_FILES, + ) + + +def _warn_capillary_walkoff( + modes: Sequence[Mode], grid: TimeGrid, boundary: Absorber +) -> None: + """Warn if the mode set's group-delay walk-off would survive the temporal collar. + + A mode set shares one co-moving frame, so the group delay between its modes is + physics the operator carries: mode ``p`` drifts against the first by the integral of + ``beta_1,p - beta_1,1`` along the fibre, 43 fs per metre between HE_11 and HE_12 of + a 125 um capillary. The span of the set, integrated over the length with the modes' + own paths, is what :func:`solprop.boundaries.warn_walkoff` needs. It is read at the + reference frequency alone: a band-wide span would be dominated by band edges + carrying nothing. + + - ``boundary``: the collar the run will use, for the length that sets its temporal + strength. A spectral-only collar has no temporal part to check. + """ + if len(modes) < 2 or not np.any(boundary.temporal_rate): + return + frame = Frame.comoving(grid) + positions = np.linspace(0.0, grid.length, _WALKOFF_SAMPLES) + delays = np.array( + [ + [frame_quantities(mode, frame, float(z))[1] for mode in modes] + for z in positions + ] + ) + spread = np.max(delays, axis=1) - np.min(delays, axis=1) + span = float(np.trapezoid(spread, positions)) + warn_walkoff(grid, span, boundary_length=boundary.in_stage_length) + + +def default_radial_order(n_modes: int) -> int: + """Return the radial order of the fixed rule a capillary run uses by default. + + The rule has to integrate the *quartic* of the mode profiles, because the Kerr + polarisation is cubic in the field and its projection multiplies by one more + profile. Those are Bessel functions of ``r/a``, so a Gauss rule converges spectrally + and the only question is where. Two measurements set the number: the quartic overlap + tensor of four HE_1m modes is converged to 2e-14 at ``n_r = 32`` + (``docs/guide/waveguides/mode-sets.md``), and the embedded error estimate for three + modes falls 1.6e-1, 2.1e-2, 4.1e-5, 7.2e-9, 1.2e-15 at ``n_r = 8, 16, 24, 32, 48``. + Thus 32 clears the alarm by two orders on three modes and is the reference's + rounding on four. Eight nodes per mode carries that scaling to larger sets, + since the highest radial order in the quartic grows with the set. + + A flat, larger order whatever the mode count would be safer and slower; 32 nodes on + the sets a capillary run actually uses, with the alarm watching, is the trade this + makes. The number rests on these two measurements and has not been calibrated + further. + + Parameters + ---------- + n_modes : int + How many modes the set carries, >= 1. + + Returns + ------- + int + Radial nodes asked of + :func:`solprop.modes.quadrature.transverse_quadrature`, which with + ``radial_rule="gauss_kronrod"`` rounds an even count up by one so that the + rule is the Kronrod extension of the half-order Gauss rule. + + Examples + -------- + >>> from solprop.driver.routes.hollow import default_radial_order + >>> default_radial_order(1), default_radial_order(4), default_radial_order(8) + (32, 32, 64) + """ + return max(_MINIMUM_RADIAL_ORDER, _RADIAL_NODES_PER_MODE * n_modes) + + +def _capillary_grid( + grid: TimeGrid | None, + length: float, + wavelength: float, + wavelength_limits: tuple[float, float] | None, + trange: float | None, + *, + envelope: bool, + thg: bool, +) -> TimeGrid: + """Return the grid a capillary run propagates on. + + - ``length``: capillary length, m. + - ``wavelength``: reference wavelength, m. + - ``wavelength_limits``: window limits, m. + - ``trange``: requested time window, s. + """ + if grid is not None: + if wavelength_limits is not None or trange is not None: + msg = ( + "give a grid or the wavelength_limits and trange to build one, not " + "both: the keywords would be ignored" + ) + given = {"wavelength_limits": wavelength_limits, "trange": trange} + raise DriverError( + msg, + parameter=( + "grid", + *(name for name, value in given.items() if value is not None), + ), + rule="grid-or-limits", + ) + return grid + if wavelength_limits is None or trange is None: + msg = ( + "a capillary run needs wavelength_limits and trange to build its grid, " + "or a grid of your own" + ) + raise DriverError( + msg, + parameter=_unset(wavelength_limits=wavelength_limits, trange=trange), + rule="grid-or-limits", + ) + with _about(renamed=_GRID_KEYWORDS): + if envelope: + return EnvelopeGrid.from_limits( + length, + wavelength, + wavelength_limits, + trange, + thg=thg, + ) + return FieldGrid.from_limits(length, wavelength, wavelength_limits, trange) + + +def _capillary_modes( + modes: int | Sequence[Mode], + radius: float | ZProfile, + mixture: Mixture, + *, + length: float, + lattice: TubeLattice | None = None, + model: MarcatiliModel, + loss: LossSpec, + cladding: GlassIndex | complex | None, +) -> ModeCollection: + """Return the mode set under the declared capillary material. + + - ``radius``: core radius in m, used when constructing a numbered mode set. + - ``length``: the run's length in m, over which a supplied set's radius is compared. + """ + if isinstance(modes, int) and modes < 1: + raise DriverError( + f"a capillary mode set needs at least one mode, got {modes}", + parameter="modes", + ) + if lattice is not None: + if not isinstance(modes, int): + raise DriverError( + "lattice is refused: the given modes carry their own geometry", + parameter=("lattice", "modes"), + rule="lattice-builds-modes", + ) + if model == "reduced": + raise DriverError( + 'lattice is incompatible with model="reduced"', + parameter=("lattice", "model"), + rule="lattice-full-model", + ) + return ModeCollection.vincetti( + radius, + count=modes, + mixture=mixture, + lattice=lattice, + loss=loss, + cladding=cladding, + ) + if isinstance(modes, int): + return ModeCollection( + tuple( + MarcatiliMode( + radius, + mixture=mixture, + m=index + 1, + model=model, + loss=loss, + cladding=cladding, + ) + for index in range(modes) + ) + ) + if not modes: + # The capillary wording, which the int branch above already uses: the + # waveguide entry has its own. + raise DriverError( + "a capillary mode set needs at least one mode", parameter="modes" + ) + given = _given_gas_modes(modes, mixture) + _refuse_ignored_geometry( + given, radius, length=length, model=model, loss=loss, cladding=cladding + ) + return given + + +# Two radius paths agree when they agree to this relative tolerance at every sampled +# position: a supplied set and a declared radius built from the same number or the +# same profile agree exactly, so this only forgives the last bits of a radius +# computed two ways, and a real disagreement is a percent or more. +_RADIUS_RTOL = 1e-12 + + +def _refuse_ignored_geometry( + modes: Sequence[Mode], + radius: float | ZProfile, + *, + length: float, + model: MarcatiliModel, + loss: LossSpec, + cladding: GlassIndex | complex | None, +) -> None: + """Refuse, by name, a declared capillary argument a supplied mode set ignores. + + A supplied set is the run's geometry: its modes keep their own radius, model, loss + and cladding and are only rebound to the declared fill, so an argument that + disagrees with them would be silently discarded. The radius is always declared -- it + is positional -- and is compared with each mode's own ``core_radius`` path over the + run, breakpoints included, so a taper beside a constant bore, or a different bore, + is named. `model`, `loss` and `cladding` are compared only when they differ from + their defaults, which is the only way this layer can tell a declaration from an + omission: a default beside a mode that says otherwise was not asked for. + + - ``modes``: the supplied set, non-empty. + - ``radius``: the declared core radius, m. + - ``length``: the run's length, m, over which the radius paths are compared. + """ + declared = as_profile(radius) + for index, mode in enumerate(modes): + own = getattr(mode, "core_radius", None) + if own is None: + msg = ( + "from_capillary's radius is the capillary's bore, and supplied mode " + f"{index} ({type(mode).__name__}) declares no core radius to compare " + "it with, so the radius would be ignored: propagate a mode set that " + "is not a capillary's with from_waveguide or prop_waveguide" + ) + raise DriverError(msg, parameter=("radius", f"modes[{index}]")) + if not _same_radius(declared, own, length): + msg = ( + f"from_capillary's radius disagrees with supplied mode {index} " + f"({mode.channel_label.display_label}): a supplied mode set is the " + "run's geometry and keeps its own core radius, so the declared one " + "would be ignored. Declare the modes' own radius (the same number or " + "profile) or build the modes on the radius wanted" + ) + raise DriverError(msg, parameter=("radius", f"modes[{index}]")) + _refuse_ignored_setting(index, mode, "model", model, default="full") + _refuse_ignored_setting(index, mode, "loss", loss, default=True) + _refuse_ignored_setting(index, mode, "cladding", cladding, default=None) + + +def _refuse_ignored_setting( + index: int, mode: Mode, name: str, declared: object, *, default: object +) -> None: + """Refuse a non-default capillary setting that a supplied mode does not carry. + + - ``name``: the keyword, which is also the mode's attribute: ``"model"``, ``"loss"`` + or ``"cladding"``. + """ + if declared is default or declared == default: + return + own = getattr(mode, name, None) + same = own is declared or ( + not callable(own) and not callable(declared) and own == declared + ) + if not same: + msg = ( + f"from_capillary's {name}={declared!r} disagrees with supplied mode " + f"{index}, which carries {name}={own!r}: a supplied mode set keeps its " + f"own {name}, so the declaration would be ignored. Declare it on the " + f"modes, or leave {name} at its default beside them" + ) + raise DriverError(msg, parameter=(name, f"modes[{index}]")) + + +def _same_radius(declared: ZProfile, own: ZProfile, length: float) -> bool: + """Return whether two radius paths agree over a run of `length`. + + - ``declared``: the declared core radius, m. + - ``own``: a supplied mode's core radius, m. + - ``length``: the run's length, m. + - Returns: whether they agree to ``_RADIUS_RTOL`` at 65 equally spaced positions, + both paths' breakpoints and the midpoints between all of them. + """ + if declared is own: + return True + start = max(0.0, declared.z_span[0], own.z_span[0]) + end = min(length, declared.z_span[1], own.z_span[1]) + edges = np.unique( + np.concatenate( + [np.linspace(start, end, 65), declared.breakpoints, own.breakpoints] + ).clip(start, end) + ) + positions = np.unique(np.concatenate([edges, 0.5 * (edges[:-1] + edges[1:])])) + return bool( + np.allclose(own(positions), declared(positions), rtol=_RADIUS_RTOL, atol=0.0) + ) + + +def _given_gas_modes(modes: Sequence[Mode], mixture: Mixture) -> ModeCollection: + """Bind supplied modes and retain the existing orthonormality gate.""" + if not modes: + raise DriverError( + "a waveguide mode set needs at least one mode", parameter="modes" + ) + rebound: list[Mode] = [] + for index, mode in enumerate(modes): + with _about(f"modes[{index}]"): + rebound.append(_rebind_mode(mode, mixture)) + given = ModeCollection(tuple(rebound)) + if len(given) > 1: + overlaps = given.orthonormality() + worst = float(np.max(np.abs(overlaps - np.eye(len(given))))) + if worst > ORTHONORMALITY_TOLERANCE: + raise DriverError( + f"the given modes are not an orthonormal set: the largest deviation " + f"of the overlap matrix from the identity is {worst:.3e}, above " + f"{ORTHONORMALITY_TOLERANCE:.0e}. One synthesis matrix and its dual " + "represent an orthonormal basis and nothing else", + parameter="modes", + ) + return given + + +_NODE_MAXIMUM_PLASMA = ( + "electron_density", + "peak_ionisation_rate", + "species_electron_density", + "species_peak_ionisation_rate", +) +"""The plasma statistics read at a sampled node rather than on the axis.""" + +_SAMPLED_PEAK_THRESHOLD = 0.999 +"""Innermost field scale below which a node maximum warns (SEP 19, Q8). + +At 0.999 the innermost node sees 99.8% of the axial intensity, so a rate of order 10 +there is within 2% of its axial value; every Gauss-Kronrod and Gauss rule in r/a of +eight or more nodes is closer still.""" + + +def _warn_sampled_peak(rule: TransverseQuadrature | None, spec: StatsSpec) -> None: + """Warn once per run when a node maximum is read far from the axis (SEP 19, Q8). + + A field-scale Gauss rule of few nodes integrates the source but has no node near + the axis, so a node-maximum plasma statistic samples the axial peak at its + innermost node, which a steep rate under-reports. The value is unchanged; the + warning names the node and the on-axis statistics that read the axis. + """ + if rule is None or rule.radial_rule != "field_scale": + return + asked = sorted(set(_NODE_MAXIMUM_PLASMA) & {*spec.names, *spec.periodic}) + if not asked: + return + eta = float(j0(jn_zeros(0, 1)[0] * float(np.min(rule.xi_r)))) + if eta < _SAMPLED_PEAK_THRESHOLD: + warnings.warn( + f"the field-scale rule's innermost node is at eta = {eta:.3f}, " + f"{eta**2:.1%} of the axial intensity, so {asked} are node maxima " + f"there: a rate of order K reads about eta^(2K) of its axial value. " + "Request the on_axis_* statistics for the axis, or use the Radau rule, " + "whose first node is on it", + SampledPeakWarning, + stacklevel=3, + ) + + +def _capillary_quadrature( + modes: ModeCollection, + n_r: int | None, + n_theta: int | None, + *, + radial_rule: RadialRule, +) -> TransverseQuadrature: + """Return the fixed transverse rule of a capillary run. + + - ``n_r``: radial nodes; ``None`` takes the module's + :func:`~solprop.driver.routes.hollow.default_radial_order`. + - ``n_theta``: azimuthal nodes; ``None`` takes the rule's own default where one is + needed. + """ + if modes.modes[0].dimlimits(0.0)[0] == "slab": + if n_theta is not None: + msg = ( + f"n_theta={n_theta} was given for a slab set: the planar guide is " + "uniform in y, so its rule has one y node of unit width and n_r " + "alone is the x node count" + ) + raise DriverError(msg, parameter="n_theta") + # The measured bound of the x rule's quartic products is the Cartesian + # per-axis one; the default takes whichever is larger. + needed = cartesian_nodes(max(mode.azimuthal_order for mode in modes.modes)) + order = max(default_radial_order(len(modes)), needed) if n_r is None else n_r + return transverse_quadrature(modes.modes, n_r=order, radial_rule=radial_rule) + if n_theta is not None and not modes.needs_full_rule: + msg = ( + f"n_theta={n_theta} was given for a set whose modes all have azimuthal " + "order zero: |J_0(u r/a)| does not depend on the angle, so the rule is " + "radial and every azimuthal node is a copy of the same integrand" + ) + raise DriverError(msg, parameter="n_theta") + order = default_radial_order(len(modes)) if n_r is None else n_r + if modes.modes[0].dimlimits(0.0)[0] == "cartesian": + # For a Cartesian set n_r and n_theta are the x and y node counts, so + # both defaults take the measured aliasing bound of the set's highest + # transverse order rather than the radial and azimuthal defaults, which + # under-resolve y from order two upwards. + needed = cartesian_nodes(max(mode.azimuthal_order for mode in modes.modes)) + return transverse_quadrature( + modes.modes, + n_r=max(order, needed) if n_r is None else n_r, + n_theta=needed if n_theta is None else n_theta, + radial_rule=radial_rule, + ) + if n_theta is None: + return transverse_quadrature(modes.modes, n_r=order, radial_rule=radial_rule) + return transverse_quadrature( + modes.modes, n_r=order, n_theta=n_theta, radial_rule=radial_rule + ) + + +def _no_quadrature( + modes: ModeCollection, + n_r: int | None, + n_theta: int | None, + *, + radial_rule: RadialRule, +) -> None: + """Check a mode-averaged hollow run and return its absent transverse rule. + + A mode-averaged run carries one mode as a uniform disc of its effective area, the + one-node transform (:func:`solprop.translate.modal.mode_averaged_transform`), so it + has no rule for the keywords that shape one to act on. + + - ``modes``: the mode set, which must hold exactly one mode. + - ``n_r``: radial nodes, which must not be given. + - ``n_theta``: azimuthal nodes, which must not be given. + - ``radial_rule``: the radial rule family, which must be the default. + """ + if len(modes) != 1: + msg = ( + f"mode_average=True needs exactly one mode, and the set has {len(modes)}: " + "energy moving between modes is what a mode-averaged run cannot carry " + "(docs/guide/waveguides/mode-averaging.md)" + ) + raise DriverError( + msg, parameter=("mode_average", "modes"), rule="mode-average-one-mode" + ) + given = [ + name + for name, value, unset in ( + ("n_r", n_r, None), + ("n_theta", n_theta, None), + ("radial_rule", radial_rule, "gauss_kronrod"), + ) + if value != unset + ] + if given: + msg = ( + f"{', '.join(given)} given beside mode_average=True: they shape a " + "transverse rule, and a mode-averaged run has none, its mode being a " + "uniform disc of the effective area" + ) + raise DriverError( + msg, parameter=("mode_average", *given), rule="mode-average-no-quadrature" + ) + + +def _capillary_responses( + gas: Gas | str, + material: Species, + *, + kerr: bool, + plasma: bool | IonisationRate, + preionisation: float | ZProfile, + integration: IntegrationPolicy | None, + wavelength: float, + envelope: bool, + electronic: KerrResponse | DispersiveKerrResponse | None, + selection: MaterialSelection, +) -> list[object]: + """Return the Kerr and plasma responses of a single-gas capillary run. + + The Raman response, optmat's, is the fill's (:func:`_optmat_raman`). + + - ``material``: the fill's species as resolved under the run's selection, which the + plasma response carries: it is never rebuilt from the name with the default + selection, so the material manifest lists only the models that acted (Q45). + - ``preionisation``: fraction already ionised before the pulse, a number or a path + along z in m. + - ``integration``: the field plasma column's running-integral rule; ``None`` takes + the response's default. + - ``wavelength``: reference wavelength, m, the rate's carrier unless the selection + names one. + - ``electronic``: the gas's electronic response under the selection's policy + (:func:`solprop.nonlinear.mixtures.electronic_response`); ``None`` without one. + """ + responses: list[object] = [] + if kerr: + # Per particle: the density path supplies the rest (ADR-026, + # docs/dev/decisions.md). + assert electronic is not None + responses.append(electronic) + if plasma is not False: + rate = _ionisation_rate(gas, plasma, wavelength, selection) + if envelope: + responses.append( + EnvelopePlasmaResponse(material, rate, preionisation=preionisation) + ) + else: + chosen: dict[str, IntegrationPolicy] = ( + {} if integration is None else {"integration": integration} + ) + responses.append( + PlasmaResponse(material, rate, preionisation=preionisation, **chosen) + ) + if not responses: + msg = ( + "a capillary run with neither kerr nor plasma has no nonlinearity; use " + "the linear propagator instead of a solver" + ) + raise DriverError( + msg, parameter=("kerr", "plasma"), rule="capillary-needs-nonlinearity" + ) + return responses + + +def _default_raman(mixture: Mixture, *, provisional: bool = False) -> bool: + """Decide what ``raman=None`` means for a fill. + + On when any component carries a Raman response: a built-in gas whose optmat + response is curated (:func:`solprop.nonlinear.optmat_raman.raman_status`), or a + custom species carrying a model with at least one term. An atom contributes + nothing and says nothing. A molecule whose optmat response is provisional (N2O's + bands, SEP 3, D13) is off and warns rather than silently going without: a user + expecting every molecule's Raman response would otherwise not learn that nitrous + oxide is the one curated only provisionally. Under + ``MaterialSelection(raman="provisional")`` it takes that response instead (Q17), + which warns once per run with its status and is recorded. + """ + on = False + for component in mixture.components: + material = component.species + if not built_in_population(material): + model = material.raman + on = on or ( + model is not None + and (model.rotational is not None or model.vibrational is not None) + ) + continue + status = raman_status(material.name) + if status == "validated" or (status == "provisional" and provisional): + on = True + elif status == "provisional": + warnings.warn( + SolpropWarning( + f"{component.label} ({material.name}) is a molecule whose Raman " + "response optmat has only provisionally, so it is off; pass " + "raman=False to say so, or MaterialSelection(raman='provisional') " + "to admit it", + parameter="raman", + ), + stacklevel=3, + ) + return on + + +def _optmat_raman( # noqa: PLR0917 - called positionally + gas: Gas | str, + mixture: Mixture, + selection: MaterialSelection, + thg: bool, + wavelength: float, + length: float, +) -> list[object]: + """Return a single-gas fill's Raman response from optmat (SEP 3). + + - ``wavelength``: the grid reference wavelength in m, the carrier unless the + selection names one. + - ``length``: the run's length in m. + """ + logger.debug("optmat Raman response of the %s fill", gas) + response = mixture_raman( + mixture, + True, + selection=selection, + thg=thg, + wavelength=wavelength, + length=length, + explicit=True, + ) + return [] if response is None else [response] + + +def _ionisation_rate( + gas: Gas | str, + plasma: bool | IonisationRate, + wavelength: float, + selection: MaterialSelection, +) -> IonisationRate: + """Return the ionisation rate a capillary run uses. + + ``plasma=True`` takes optmat's rate model + (:func:`solprop.nonlinear.optmat_rates.selected_rate`, SEP 3, D1); an explicit + rate object passes through unchanged. The models' physical limits are discussed + in ``docs/guide/media/ionisation.md``. + + - ``wavelength``: reference wavelength, m, the rate's carrier unless the selection + names one. + """ + if isinstance(plasma, bool): + return selected_rate(species(gas, selection=selection), selection, wavelength) + return plasma + + +def _with_integration_statistics( + spec: StatsSpec, responses: Sequence[object] +) -> StatsSpec: + """Add the plasma integration diagnostics to a default spec under the band rule. + + The undershoot, work defect and boundary level of SEP 18 are recorded by default + wherever the field plasma column integrates by the band rule, so the run's warning + can read them back; a run on the plain trapezoid records none of them. + """ + if not any( + isinstance(one, PlasmaResponse) and one.integration == "band" + for one in responses + ): + return spec + extra = tuple( + name for name in PLASMA_INTEGRATION_STATISTICS if name not in spec.names + ) + return dataclasses.replace(spec, names=(*spec.names, *extra)) + + +def _capillary_stats( + *, with_plasma: bool, mixture: Mixture, averaged: bool = False +) -> StatsSpec: + """Return the statistics a capillary run records unless told otherwise. + + The multimode defaults with the z-profiles, plus every statistic only a backend can + compute: it holds the nodes the plasma was evaluated at, the mode set the axis + needs, and the embedded quadrature estimate a later pass cannot reconstruct. + + - ``averaged``: whether the run is mode-averaged: the mode-averaged defaults then + replace the multimode ones, and the quadrature estimate, which has no rule to + read, is left out. + """ + wanted = [ + name + for name in BACKEND_STATISTICS + if (with_plasma or name not in PLASMA_STATISTICS) + and not (averaged and name == "quadrature_error") + ] + base = default_spec( + "mode_averaged" if averaged else "multimode", with_profiles=True + ) + wanted.extend( + ( + "species_ionisation", + "species_electron_density", + "species_peak_ionisation_rate", + "species_density", + "species_ionisation_energy", + ) + ) + if mixture.shared is None: + base = dataclasses.replace( + base, names=tuple(name for name in base.names if name != "pressure") + ) + return dataclasses.replace(base, names=(*base.names, *wanted)) + + +def _hollow_components(*, supplied: bool) -> tuple[str, ...] | None: + """Return the transverse components a hollow-guide run resolves. + + The capillary route's own set (a mode count) is unrotated HE_1m, which points along + x (``docs/dev/conventions.md`` section 5), and keeps ``("x",)`` bitwise. A supplied + set -- through either entry, with or without a coherent wall -- resolves the + components its modes need (``None``, inferred by + :func:`solprop.translate.problem.assemble`), so a TE_0m, TM_0m, EH or rotated set + keeps its E_y. A wall's coupled flow contracts the channel axis of the one-component + state and never reads the synthesis, so the backends admit it beside two synthesised + components. ``from_waveguide`` refuses a mode count before this, so only the + capillary entry reaches the first branch. + + - Returns: ``("x",)``, or ``None`` to infer the components from the modes. + """ + return None if supplied else DEFAULT_COMPONENTS + + +def _rebind_mode(mode: Mode, mixture: Mixture) -> Mode: + """Bind a gas fill through the mode's explicit material interface. + + The mode's own gas, pressure and temperature (a temperature path included) are + replaced by the mixture's; only its geometry is kept. + """ + rebind: Callable[[Mixture], Mode] | None = getattr(mode, "with_mixture", None) + if not callable(rebind): + # The hint belongs to the mode that has a route: a step-index fibre is a + # mode-averaged GNLSE run. Anything else gets the plain refusal. + message = f"{type(mode).__name__} cannot replace its material with a Mixture" + if isinstance(mode, StepIndexMode): + message += "; use prop_gnlse(fibre=...) instead" + raise DriverError(message) + return rebind(mixture) + + +def _hollow_stats( + *, + with_plasma: bool, + mixture: Mixture, + profiles: Mapping[str, ZProfile], + rule: TransverseQuadrature | None, +) -> StatsSpec: + """Keep capillary statistics where the supplied geometry defines them. + + - ``rule``: the run's fixed transverse rule, whose embedded coarse rule the + quadrature alarm needs; ``None`` for a mode-averaged run. + - Returns: statistics with unsupported geometry paths omitted. + """ + base = _capillary_stats( + with_plasma=with_plasma, mixture=mixture, averaged=rule is None + ) + dropped = {name for name in ("core_radius",) if name not in profiles} + if rule is not None and not rule.kronrod: + # Without an embedded coarse rule the estimate is NaN at every plane, so + # the statistic is dropped rather than recorded empty. A Cartesian + # product rule has no Kronrod extension at all (quadrature_rule stores + # kronrod only for a polar rule), so the alarm is off for every + # rectangular run, and the log below says so rather than leaving it silent. + dropped.add("quadrature_error") + logger.info( + "the %s rule has no embedded coarse rule, so the quadrature alarm is " + "off and quadrature_error is not recorded", + rule.kind, + ) + return dataclasses.replace( + base, names=tuple(name for name in base.names if name not in dropped) + ) diff --git a/src/solprop/driver/routes/keywords.py b/src/solprop/driver/routes/keywords.py new file mode 100644 index 00000000..af0aa379 --- /dev/null +++ b/src/solprop/driver/routes/keywords.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Name the keyword a refusal or a warning concerns (SEP 41, S10). + +A keyword route builds physics objects from its keywords; an error or a warning those +objects raise is attributed here to the keyword the caller typed. +""" + +import contextlib +import warnings +from collections.abc import Iterator, Mapping +from typing import Final, cast + +from solprop.exceptions import ( + MaterialRangeWarning, + MaterialStatusWarning, + ParameterPath, + SolpropError, + SolpropWarning, +) + +__all__ = [] + + +@contextlib.contextmanager +def _about( + parameter: ParameterPath | None = None, + renamed: Mapping[str, str] | None = None, +) -> Iterator[None]: + """Name the keyword a refusal raised in the block concerns (SEP 41, S10). + + An object built from a keyword's value refuses it by its own argument's name, or by + none; the interface knows which keyword it passed, so it names that one here. The + exception keeps its object, class, message and traceback. + + - ``parameter``: the path given to a :class:`~solprop.exceptions.SolpropError` that + names no parameter; ``None`` leaves it unnamed. + - ``renamed``: the inner call's argument names that differ from the keywords that + supplied them, each mapped to its keyword: a path the error names already is + renamed through it (each path of a conflict), and kept where it has no entry. + """ + try: + yield + except SolpropError as error: + if error.parameter is None: + error.parameter = parameter + elif renamed: + error.parameter = ( + renamed.get(error.parameter, error.parameter) + if isinstance(error.parameter, str) + else tuple(renamed.get(path, path) for path in error.parameter) + ) + raise + + +_REISSUED: Final[dict[str | tuple[str, type[Warning], int], int]] = {} +"""The registry of the warnings :func:`_warnings_about` issues again. + +It stands in for the issuing module's own, so that a ``"default"`` filter still shows +a warning once per location and text rather than once per run. +""" + + +@contextlib.contextmanager +def _warnings_about( + parameter: ParameterPath, categories: tuple[type[SolpropWarning], ...] +) -> Iterator[None]: + """Name the keyword the value warnings issued in the block concern (SEP 41, S10). + + A material model warns deep inside the physics, where the keyword that chose the + material is not known. The block's warnings are recorded and, when it ends, issued + again in order at their own locations and through the caller's filters, so that an + ``"error"`` filter still raises and an ``"ignore"`` one still hides them: a warning + of one of `categories` that names no parameter is given `parameter` first, and + every other warning is issued as it was. + """ + caught: list[warnings.WarningMessage] = [] + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + yield + finally: + for record in caught: + # A recorded warning is an instance of its category, never a string. + message = cast("Warning", record.message) + if isinstance(message, categories) and message.parameter is None: + message.parameter = parameter + warnings.warn_explicit( + message, + record.category, + record.filename, + record.lineno, + registry=_REISSUED, + source=record.source, + ) + + +_MATERIAL_WARNINGS: Final = (MaterialRangeWarning, MaterialStatusWarning) +"""The warnings a material model issues about the conditions it is evaluated at.""" + +_GRID_KEYWORDS: Final[Mapping[str, str]] = {"reference_wavelength": "wavelength"} +"""The grid constructors' argument names that a keyword route spells otherwise.""" + +_PULSE_KEYWORDS: Final[Mapping[str, str]] = {"phase": "phases"} +"""The pulse constructors' argument names that a keyword route spells otherwise.""" + + +def _path(*names: str) -> ParameterPath: + """Return the path of one keyword, or the tuple of several in conflict. + + - ``*names``: the keywords, at least one. + """ + return names[0] if len(names) == 1 else names + + +def _unset(**keywords: object) -> ParameterPath: + """Return the path of the keywords among `keywords` left unset. + + - ``**keywords``: the keywords a refusal needs, by name; at least one is ``None``. + """ + return _path(*(name for name, value in keywords.items() if value is None)) diff --git a/src/solprop/driver/routes/materials.py b/src/solprop/driver/routes/materials.py new file mode 100644 index 00000000..50b44c93 --- /dev/null +++ b/src/solprop/driver/routes/materials.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Resolve a route's built-in materials under a material selection (SEP 3). + +Also the policy checks a route applies to its selection: the carrier, and the +electronic policy its responses can honour. +""" + +import dataclasses +from collections.abc import Sequence + +from solprop.exceptions import DriverError +from solprop.modes.capillary import GlassIndex +from solprop.modes.graded_index import GradedIndexProvider +from solprop.modes.guide import ModalGuide, StepIndexProvider +from solprop.modes.material import MaterialRegion +from solprop.nonlinear.raman import SilicaTensorResponse +from solprop.nonlinear.responses import KerrResponse +from solprop.physdata import fused_silica +from solprop.physdata.glasses import SilicaBundle +from solprop.physdata.nonlinear_data import carriers_agree +from solprop.physdata.selection import DISPERSIVE_POLICIES, MaterialSelection + +__all__ = [] + + +def _refuse_dispersive_policy(materials: MaterialSelection | None, route: str) -> None: + """Refuse a dispersive electronic policy on a route whose response is constant.""" + if materials is None or materials.electronic not in DISPERSIVE_POLICIES: + return + msg = ( + f"the electronic policy {materials.electronic!r} evaluates optmat's " + f"dispersive hyperpolarizability model, and {route} is a constant coefficient " + "this route keeps: select 'carrier-kerr' (or 'recommended') here" + ) + raise DriverError(msg, parameter="materials.electronic", rule="gnlse-carrier-kerr") + + +def _check_electronic_policy( + materials: MaterialSelection | None, responses: Sequence[object] +) -> None: + """Refuse a constant Kerr response under a dispersive electronic policy. + + A caller's own responses are taken as they are, but a constant + :class:`~solprop.nonlinear.responses.KerrResponse` beside a selection that names a + dispersive policy would record one law and propagate another. + """ + if materials is None or materials.electronic not in DISPERSIVE_POLICIES: + return + if any(isinstance(response, KerrResponse) for response in responses): + msg = ( + f"the selection's electronic policy is {materials.electronic!r} and the " + "run declares a constant KerrResponse; build the electronic response as " + "a DispersiveKerrResponse (solprop.nonlinear.mixtures." + "electronic_response), or select 'carrier-kerr'" + ) + raise DriverError(msg) + + +def _check_selection_carrier( + materials: MaterialSelection | None, + reference: float, + wavelength: float | None = None, +) -> None: + """Refuse a carrier that is not the run's: one carrier sets the Kerr coefficient. + + The electronic coefficient, the Raman response and the rate are all evaluated at + the carrier. + + - ``reference``: the grid's reference wavelength in m, the run's carrier. + - ``wavelength``: the driver's `wavelength` in m, at which the responses are built. + """ + if materials is None: + return + # Without the driver's wavelength the reference is a grid given whole. + carrier = ( + "materials.carrier_wavelength", + "grid" if wavelength is None else "wavelength", + ) + for name, value, parameter in ( + ("the selection's carrier_wavelength", materials.carrier_wavelength, carrier), + ("wavelength", wavelength, ("wavelength", "grid")), + ): + if value is not None and not carriers_agree(float(value), reference): + msg = ( + f"{name} is {value:g} m and the grid's reference wavelength " + f"{reference:g} m: under a material selection one carrier sets the " + "Kerr coefficient of every optmat species (SEP 3, section 4.3), and " + "it is the grid's. Build the grid at that wavelength, or leave the " + "selection's carrier_wavelength unset" + ) + raise DriverError(msg, parameter=parameter) + + +def _default_cladding(cladding: GlassIndex | complex | None) -> GlassIndex | complex: + """Return the cladding a numbered capillary set takes. + + - ``cladding``: the cladding given; ``None`` is the package default. + """ + return fused_silica() if cladding is None else cladding + + +def _selected_guide(guide: ModalGuide, materials: MaterialSelection) -> ModalGuide: + """Resolve a guide's default delayed silica response under a material selection. + + A region's :class:`~solprop.nonlinear.raman.SilicaTensorResponse` with the + default parameters, optmat's recommended tensor bundle, meant the built-in silica + tensor response; under the selection it becomes the tensor bundle its ``raman`` + names (:meth:`~solprop.physdata.glasses.SilicaBundle.resolve` with + ``tensor=True``). A response declared with any other parameters, and a provider + of another kind, are taken as they are. + + - Returns: the guide to propagate on: the one given when nothing resolves, otherwise + the same provider, channels and sampling with the regions' delayed responses + resolved. + """ + default = SilicaTensorResponse() + if not any(region.raman == default for region in guide.regions): + return guide + provider = guide.field_provider + if not isinstance(provider, StepIndexProvider | GradedIndexProvider): + return guide + resolved = SilicaTensorResponse( + parameters=SilicaBundle.resolve(materials.raman, tensor=True) + ) + + def selected(region: MaterialRegion) -> MaterialRegion: + """Return a region with its default delayed response resolved.""" + return dataclasses.replace( + region, + responses=tuple( + resolved if one == default else one for one in region.responses + ), + ) + + rebuilt = ( + dataclasses.replace( + provider, regions=tuple(selected(one) for one in provider.regions) + ) + if isinstance(provider, StepIndexProvider) + else dataclasses.replace( + provider, + core_responses=selected(provider.regions[0]).responses, + ) + ) + return dataclasses.replace( + guide, field_provider=rebuilt, regions=tuple(rebuilt.regions) + ) diff --git a/src/solprop/driver/routes/spatial.py b/src/solprop/driver/routes/spatial.py new file mode 100644 index 00000000..86b99a57 --- /dev/null +++ b/src/solprop/driver/routes/spatial.py @@ -0,0 +1,349 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The spatial routes: free space and planar grids, and their transverse design.""" + +import dataclasses +import warnings +from collections.abc import Sequence +from typing import TYPE_CHECKING, Literal, cast + +import numpy as np +from jaxtyping import Float + +from solprop.boundaries import SpatialAbsorber +from solprop.driver.defaults import resolve_noise +from solprop.driver.inputs import ( + _bind_spatial_rates, + _check_spatial_input, + _redrawable, + _spatial_draw, +) +from solprop.driver.mixtures import mixture_profiles +from solprop.driver.settings import ( + NoiseSetting, + SimulationSettings, + _spatial_default, + _spatial_settings, +) +from solprop.exceptions import BoundaryWarning, DriverError, TranslationError +from solprop.grid import FreeGrid, RadialGrid, TimeGrid +from solprop.linear import Frame +from solprop.modes.slab import PlanarGrid, SlabMode +from solprop.physdata import Gas, Mixture, as_gas, fused_silica, species +from solprop.physdata.selection import MaterialSelection +from solprop.physdata.species import default_species +from solprop.profiles import ZProfile +from solprop.pulses import SpatialPulse, SpatialPulseSum +from solprop.spatial import SpatialDispersion, SpatialMedium +from solprop.spatial_absorber import SpatialAbsorberDesign +from solprop.stats import default_spec +from solprop.translate.problem import PropagationProblem +from solprop.translate.responses import declares_harmonic_carrier +from solprop.translate.routes import transverse_of +from solprop.translate.transverse import checked_design, resolve_transverse + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [] + + +def _from_spatial( # noqa: PLR0917 - called positionally + cls: type["Simulation"], + grid: TimeGrid, + space: RadialGrid | FreeGrid, + medium: SpatialMedium, + responses: Sequence[object], + initial: SpatialPulse | SpatialPulseSum | np.ndarray, + *, + settings: SimulationSettings, + frame: Frame | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + materials: MaterialSelection | None = None, +) -> "Simulation": + """Build a radial or Cartesian propagation using physical fields. + + - ``space``: radial nodes, outer radius and spatial collar, or the Cartesian + axes and their collar. + - ``initial``: beam specification or internal spectrum, a Hankel spectrum in V m + or a raw Cartesian DFT spectrum in V/m, with shape ``(n_columns, n_pol, + n_omega)``. + - ``settings``: step tolerances, positive save positions in m, statistics and + backend. Absolute tolerance has the input spectrum's units. + - ``frame``: moving frame. None uses the declared responses' THG choice. + - ``seed``: random generator seed; None draws and records system entropy. + - Returns: cached translation and the ordinary resumable execution interface. + """ + chosen_frame = frame + if chosen_frame is None: + chosen_frame = Frame.comoving( + grid, thg=declares_harmonic_carrier(responses, grid) + ) + if isinstance(medium.material, Mixture) and settings.stats is None: + base = default_spec("radial" if isinstance(space, RadialGrid) else "free") + settings = dataclasses.replace( + settings, + stats=dataclasses.replace( + base, + names=( + *base.names, + "species_ionisation", + "species_electron_density", + "species_peak_ionisation_rate", + "species_density", + "species_ionisation_energy", + ), + ), + ) + settings = _spatial_default(settings, grid) + if isinstance(initial, np.ndarray): + # Refused before the simulation copies it. + _check_spatial_input(initial.shape, space.n_columns, grid.n_omega) + simulation = cls( + grid, + (), + responses, + initial, + settings=_spatial_settings( + settings, "radial" if isinstance(space, RadialGrid) else "free" + ), + profiles=mixture_profiles(medium.material) + if isinstance(medium.material, Mixture) + else {"density": medium.density}, + spatial_grid=space, + medium=medium, + frame=chosen_frame, + materials=materials, + ) + _check_spatial_input(simulation._field0.shape, space.n_columns, grid.n_omega) + noise_model, resolution = resolve_noise( + "radial" if isinstance(space, RadialGrid) else "free", + noise, + seed, + ) + simulation.noise_resolution = resolution + if noise_model is not None: + draw = _spatial_draw(noise_model, simulation, medium) + simulation.noise = draw(seed) + _redrawable(simulation, draw, seed) + _bind_spatial_rates(simulation, initial) + return simulation + + +def _rechecked( + simulation: "Simulation", + original: PropagationProblem, + derived: PropagationProblem, + planes: Float[np.ndarray, " n_z"], +) -> SpatialAbsorberDesign | None: + """Return a derived run's transverse design, its check rerun on the new launch. + + SEP 40 section 5.2: a derived run keeps its parent's design, which its shared + operator and transform were built with, but not its verdict, which belongs to a + launch. Only a scalar multiple of the checked launch reuses it, because the check + is linear and normalised by the launch's energy. A new launch whose rms + wavenumber lies above the one the band was derived from warns as well. + + - ``planes``: the derived run's save planes, m. + """ + design = transverse_of(original.route) + if design is None or not design.has_collar: + return design + space = simulation.spatial_grid + medium = simulation.medium + assert isinstance(space, RadialGrid | FreeGrid | PlanarGrid) + assert medium is not None + old = np.asarray(original.initial.field0) + new = np.asarray(derived.initial.field0) + reference = float(np.vdot(old, old).real) + if reference > 0.0: + scale = np.vdot(old, new) / reference + residual = float(np.linalg.norm(new - scale * old)) + if residual <= 1e-12 * float(np.linalg.norm(new)): + return design + length = float(np.max(planes)) - float(derived.initial.z) + dispersion = SpatialDispersion( + medium, simulation.grid, Frame.comoving(simulation.grid) + ) + if design.specification.band_source == "launch": + before = resolve_transverse( + design.specification, space, dispersion, old, length + ) + after_kappa = _launch_band(design.specification, space, dispersion, new, length) + if after_kappa is not None and after_kappa > max(before.kappa_p) * (1 + 1e-9): + warnings.warn( + "the new launch spreads beyond the band its run's collar was designed " + f"for ({after_kappa:.3g} > {max(before.kappa_p):.3g} rad/m): what it " + "carries above that band is absorbed only partly; translate afresh", + BoundaryWarning, + stacklevel=3, + ) + try: + return checked_design( + dataclasses.replace(design, check={}), space, dispersion, new, length + ) + except TranslationError as error: + msg = ( + f"{error}. The grid is shared with the parent run, so translate a new " + "simulation with that collar_fraction for this launch" + ) + raise DriverError(msg) from error + + +def _launch_band( + specification: SpatialAbsorber, + space: RadialGrid | FreeGrid | PlanarGrid, + dispersion: SpatialDispersion, + launch: np.ndarray, + length: float, +) -> float | None: + """Return the widest protected band a launch would derive, or ``None``. + + - ``launch``: the launch, canonical layout. + - ``length``: the run's length, m. + - Returns: the widest axis's band edge, rad/m; ``None`` when it cannot be resolved. + """ + try: + return max( + resolve_transverse(specification, space, dispersion, launch, length).kappa_p + ) + except TranslationError: + return None + + +def _refuse_pending_check(problem: PropagationProblem) -> None: + """Refuse to start a run whose collar's design check never saw a launch. + + A coupled child is translated before its launch exists; its check runs when + :meth:`Simulation.with_input` gives it one (SEP 40 section 5.2). + """ + design = transverse_of(problem.route) + if design is not None and design.check.get("status") == "pending": + msg = ( + "this run's collar has not been checked against a launch: give it one " + "with with_input before running it (SEP 40 section 5.2)" + ) + raise DriverError(msg) + + +def _selected_medium( + medium: SpatialMedium, materials: MaterialSelection +) -> SpatialMedium: + """Resolve a bulk medium's built-in gas under a material selection. + + A medium that names a built-in gas meant the built-in gas: it becomes that gas's + one-population mixture with the selection's models, at the same pressure path and + temperature, so that it is recorded; a supplied susceptibility is kept beside + optmat's equation of state, as for a capillary fill. Any other medium is taken as + it is. + """ + if not isinstance(medium.material, Gas): + return medium + gas = medium.material + selected = species(gas, selection=materials) + if medium.susceptibility != default_species(gas).susceptibility: + selected = dataclasses.replace(selected, susceptibility=medium.susceptibility) + # A constructed medium holds its resolved temperature, a number. + fill = Mixture.fill( + {selected: 1.0}, medium.pressure, temperature=cast("float", medium.temperature) + ) + return SpatialMedium(fill) + + +def _selected_planar(space: PlanarGrid, materials: MaterialSelection) -> PlanarGrid: + """Resolve a planar grid's built-in materials under a material selection. + + The slab modes' fill of a built-in gas with its default susceptibility meant the + built-in gas: it takes the selection's single-species susceptibility + (:func:`solprop.physdata.species.species`), and keeps its pressure, temperature + and optmat density path, so no mixture -- which the hybrid transform does not + admit -- is needed (SEP 3, Q5). A slab declared without a cladding takes optmat's + recommended silica. A supplied susceptibility and a declared cladding are the + caller's. + + - Returns: the grid given when nothing resolves; otherwise the same grid rebuilt on + the resolved slab modes. + """ + modes = cast("tuple[SlabMode, ...]", tuple(space.modes)) + first = modes[0] + changes: dict[str, object] = {} + if first.mixture is None and first.gas is not None: + gas = as_gas(first.gas) + if first.susceptibility == default_species(gas).susceptibility: + # The modes share one fill (`PlanarGrid` refuses any other), so one + # resolved susceptibility serves them all and the medium is checked + # against them by identity. + resolved = species(gas, selection=materials).susceptibility + if resolved != first.susceptibility: + changes["susceptibility"] = resolved + cladding = fused_silica() + rebuilt = tuple( + dataclasses.replace( + mode, + **changes, + **({"cladding": cladding} if mode.cladding is None else {}), + ) + if changes or mode.cladding is None + else mode + for mode in modes + ) + if all(one is two for one, two in zip(rebuilt, modes, strict=True)): + return space + with warnings.catch_warnings(): + # The caller's own grid already said so if its n_x is below the bound. + warnings.filterwarnings( + "ignore", message=r"PlanarGrid n_x = ", category=UserWarning + ) + return dataclasses.replace(space, modes=rebuilt) + + +def _planar_medium(space: PlanarGrid) -> SpatialMedium: + """Return the fill of a planar grid's modes as a constant bulk medium.""" + mode = cast("SlabMode", space.modes[0]) + if mode.mixture is not None: + msg = ( + "a planar run takes a single-gas fill: a mixture on the hybrid planar " + "transform is not admitted yet; fill the slab with gas= and pressure=" + ) + raise DriverError(msg) + if not mode.density.is_constant: + msg = ( + "the hybrid planar transform requires constant media: a gas path along z " + "(a varying pressure or temperature) is refused, as for Cartesian free " + "space, whose seam evaluates a constant operator exactly and has no " + "varying-medium expansion" + ) + raise DriverError(msg) + if mode.gas is None: + return SpatialMedium() + return SpatialMedium( + mode.gas, + cast("float | ZProfile", mode.pressure), + temperature=cast("float", mode.temperature), + susceptibility=mode.susceptibility, + ) + + +def _transverse_specification( + setting: SpatialAbsorber | Literal[False] | None, + spatial_grid: object, +) -> SpatialAbsorber | None: + """Return the transverse boundary a run's translation resolves (SEP 40). + + - ``spatial_grid``: the run's transverse grid, ``None`` for a modal run. + - Returns: the specification; ``None`` for no ramp and no collar. + """ + if spatial_grid is None: + if isinstance(setting, SpatialAbsorber): + msg = ( + "spatial_absorber sets a free-space run's transverse boundary; a " + "modal route has no transverse window" + ) + raise DriverError(msg) + return None + if setting is False: + return None + return SpatialAbsorber(real_space=False) if setting is None else setting diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index df0341d7..53dc7e9c 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -349,7 +349,7 @@ def run_problem( stores it twice. The session takes the start's field. metadata : solprop.output.schema.RunMetadata The record of the run, written when the handler opens. - :func:`solprop.driver.simulation.metadata_for` builds one. + :func:`solprop.driver.metadata.metadata_for` builds one. stats_spec : solprop.stats.StatsSpec, optional Which statistics to collect at each plane. Defaults to the backend's request's own (section 8 of ``docs/dev/kernel-contract.md``). @@ -432,7 +432,7 @@ def run_problem( >>> from solprop.backends.registry import select >>> from solprop.backends.session import Start >>> from solprop.driver.run import run_problem - >>> from solprop.driver.simulation import metadata_for + >>> from solprop.driver.metadata import metadata_for >>> from solprop.grid import EnvelopeGrid >>> from solprop.nonlinear.gnlse import problem_from >>> from solprop.output import MemoryOutput @@ -671,7 +671,7 @@ def field_retention( """Return a run's field retention, resolved against its saved planes. The driver's door to :func:`solprop.output.schema.field_retention_refusal`, the - validator :class:`~solprop.driver.simulation.SimulationSettings` and + validator :class:`~solprop.driver.settings.SimulationSettings` and :func:`~solprop.driver.scan.run_scan` share. Parameters diff --git a/src/solprop/driver/runner.py b/src/solprop/driver/runner.py new file mode 100644 index 00000000..3c6d4625 --- /dev/null +++ b/src/solprop/driver/runner.py @@ -0,0 +1,674 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Run one translated simulation many times on one backend, input by input. + +:class:`SimulationRunner` keeps a backend between runs that differ only in their input, +and :func:`check_derived_problem` refuses a derived problem that differs in anything +else. The largest field each optmat rate saw is reported here after a run, a runner's +or a simulation's own. The manual page is ``docs/guide/setup/running.md``. +""" + +import dataclasses +import functools +import logging +import threading +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +import numpy as np + +from solprop._display import Displayed, line +from solprop.backends.protocol import Backend +from solprop.backends.registry import select +from solprop.backends.session import Start +from solprop.driver.inputs import ( + _borrowed_input, + _realisation, + _without_input, +) +from solprop.driver.resume import _open_output +from solprop.driver.routes.hollow import _warn_sampled_peak +from solprop.driver.run import field_retention, run_problem +from solprop.driver.settings import _same_setting +from solprop.exceptions import DriverError, MaterialRangeWarning +from solprop.log import ( + DEFAULT_PROGRESS_PERIOD, + ProgressChoice, + ProgressReporter, + StopFlag, +) +from solprop.modes.material import JsonValue +from solprop.monitor import BoundaryMonitor +from solprop.noise import NoiseRealisation +from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse +from solprop.nonlinear.optmat_rates import ( + OptmatRate, + PeakFieldReport, + peak_field_report, +) +from solprop.nonlinear.responses import PlasmaResponse +from solprop.output.hdf5 import HDF5Output +from solprop.output.schema import ( + PEAK_FIELDS_ANNOTATION, + FieldRetention, + Output, + PlaneStore, + RunMetadata, +) +from solprop.physdata import _optmat +from solprop.physdata.selection import MaterialLog +from solprop.pulses import Pulse, SpatialPulse, SpatialPulseSum +from solprop.translate.envelope_plasma import CycleIntegralData, cycle_moments +from solprop.translate.problem import PropagationProblem +from solprop.translate.routes import flux_of, tdse_descriptor, tdse_of +from solprop.translate.run_request import RunRequest + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = ["SimulationRunner", "check_derived_problem"] + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class _LoadedRun: + """What a runner's march needs, its input held by its start alone. + + Attributes + ---------- + problem : solprop.translate.problem.PropagationProblem + The derived problem, without its input (:func:`_without_input`). + start : solprop.backends.session.Start + The input's start. The run's session takes its field, so that nothing holds + the input through the march (issue #447). + handler : solprop.output.schema.Output + Where the planes go. + metadata : solprop.output.schema.RunMetadata + The run's record. + retention : solprop.output.schema.FieldRetention + Which planes keep their field. + boundary_monitor : solprop.monitor.BoundaryMonitor or None + The run's monitor of the window's edges. + material_log : solprop.physdata.selection.MaterialLog + The derived run's material log, which the march goes on with. + """ + + problem: PropagationProblem + start: Start + handler: Output + metadata: RunMetadata + retention: FieldRetention + boundary_monitor: BoundaryMonitor | None + material_log: MaterialLog + + +class SimulationRunner(Displayed): + """One translated problem and one backend, run for many inputs (SEP 22 section 5.2). + + Built by :meth:`~solprop.driver.simulation.Simulation.runner`: the problem is + translated (or the cached translation reused) and one backend is constructed, once. + Each :meth:`run` then derives the input with + :meth:`~solprop.driver.simulation.Simulation.with_input`, loads it into the same + backend and runs the save-plane loop, returning that input's own handler. A large + problem so pays its backend's allocation and transform planning once rather than for + every input. + + A run on the reused backend repeats a fresh backend's run, to the rounding that + separates any two runs: loading an input restores everything the march reads + (the reference's :meth:`solprop.backends.host.driver.HostDriver.set_initial`, the + native backend's own load). Each run's record is + its own input's, so each has its own configuration hash. + + Use it as a context manager, or call :meth:`close`: the backend holds about + fifteen copies of the state until then. A closed runner refuses :meth:`run`. A + run that raises leaves the runner open, since the next input resets everything; + a backend that closed itself closes the runner. + + Parameters + ---------- + simulation : Simulation + The run to repeat. Its settings, and the translation they fix, are used as + they are; only the input changes from run to run. + + Raises + ------ + DriverError + If the simulation has a TDSE response, whose atomic binding has its own + lifecycle and is never reused. + + Examples + -------- + >>> from solprop.driver import Simulation + >>> from solprop.pulses import SechPulse + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, + ... ) + >>> def pulse(power): + ... return SechPulse(wavelength=835e-9, width=100e-15, peak_power=power) + >>> with simulation.runner() as runner: + ... outputs = [ + ... runner.run(pulse(power), configuration={"peak_power": power}, + ... progress="none") + ... for power in (5.0, 9.8) + ... ] + >>> [output.n_saved for output in outputs], runner.closed + ([3, 3], True) + """ + + def __init__(self, simulation: "Simulation") -> None: + """Translate, admit and construct the backend, once.""" + problem = simulation.translate() + if tdse_descriptor(problem.route) is not None: + msg = ( + "a TDSE response binds an atomic runtime with its own lifecycle, which " + "a runner cannot reuse across inputs; run each input with " + "simulation.with_input(...).run()" + ) + raise DriverError(msg) + settings = simulation.settings + request = simulation._run_request() + self._simulation = simulation + self._problem = problem + self._request = request + self._lock = threading.Lock() + self._closed = False + self.runs = 0 + with _optmat.material_context(simulation._material_log): + _warn_sampled_peak(settings.quadrature, request.observation.stats) + self._backend = select( + settings.backend, + problem, + request, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + options=settings.execution_options(), + ) + + def __repr__(self) -> str: + """Return a one-line description: the simulation, the runs and the state. + + Returns + ------- + str + For example ``SimulationRunner(Simulation(...), 3 runs, open)``. + """ + return line( + "SimulationRunner", + [ + ("", repr(self._simulation)), + ("", f"{self.runs} runs"), + ("", "closed" if self._closed else "open"), + ], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + return "SimulationRunner", [ + ("simulation", repr(self._simulation)), + ("backend", self._simulation.settings.backend), + ("runs", self.runs), + ("state", "closed" if self._closed else "open"), + ] + + def __enter__(self) -> "SimulationRunner": + """Return the runner, so that a ``with`` block closes it on the way out. + + Returns + ------- + SimulationRunner + This runner. + """ + return self + + def __exit__(self, *details: object) -> None: + """Close the runner when the block ends, whatever happened inside it. + + Parameters + ---------- + *details : object + The exception being raised, if any; it is not suppressed. + """ + self.close() + + @property + def closed(self) -> bool: # numpydoc ignore=RT01 + """Whether the runner, and its backend, are closed.""" + return self._closed + + @property + def backend(self) -> Backend: # numpydoc ignore=RT01 + """Backend every run is stepped on.""" + return self._backend + + def close(self) -> None: + """Close the backend, releasing its buffers; calling it twice does nothing. + + A run in progress on another thread finishes first: the close waits for it. + """ + with self._lock: + self._close_held() + + def _close_held(self) -> None: + """Close the backend, the runner's lock being held by the caller.""" + if self._closed: + return + self._closed = True + self._backend.close() + + def run( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + *, + output: Output | Path | str | None = None, + configuration: Mapping[str, object] | None = None, + noise: NoiseRealisation | Literal["keep"] | None = "keep", + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, + input_identity: str | None = None, + ) -> Output: + """Run one input on the runner's backend and return its handler. + + Parameters + ---------- + initial : Pulse, SpatialPulse or numpy.ndarray + The input, as :meth:`~solprop.driver.simulation.Simulation.with_input` takes + it. An array the record keeps by digest + (:func:`~solprop.output.schema.recorded_input`) is not copied: it is read + while this call loads it, and the runner holds no reference to it, nor to + any copy, once it is loaded, so a caller that keeps none itself has it freed + before the first step. + output : solprop.output.schema.Output or pathlib.Path or str or None, optional + Where this input's planes go: a handler, a path for an HDF5 file, or + ``None`` for a new :class:`~solprop.output.memory.MemoryOutput`. + configuration : collections.abc.Mapping or None, optional + Caller keys recorded in this input's run record and hash, such as the + delay of a scan (:meth:`~solprop.driver.simulation.Simulation.with_input`). + noise : NoiseRealisation, {'keep'} or None, optional + The noise, as for :meth:`~solprop.driver.simulation.Simulation.with_input`: + kept by default, or replaced by another realisation of the same model. + progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional + Where progress goes (:func:`solprop.driver.run.run_problem`). + progress_period : float, optional + Seconds between the line reporter's periodic lines, s. + input_identity : str or None, optional + What an array input is, recorded in its place and not checked, so that its + bytes are not digested + (:meth:`~solprop.driver.simulation.Simulation.with_input`, whose warning + applies: a wrong identity passes a resume and a scan's cache). + + Returns + ------- + solprop.output.schema.Output + This input's handler, closed; a memory handler stays readable. + + Raises + ------ + DriverError + If the runner is closed or already running an input; if the derived + problem is not the runner's but for its input (a different launch + position, or a shared leaf replaced); if the noise model differs from + the runner's, which the backend could not carry; or if `input_identity` + is empty or names anything but an array input. + """ + if not self._lock.acquire(blocking=False): + msg = ( + "this runner is already running an input: one backend steps one input " + "at a time, so build one runner per thread or process" + ) + raise DriverError(msg) + try: + if self._closed: + msg = "this runner is closed; build another with simulation.runner()" + raise DriverError(msg) + loaded = self._load( + initial, + output=output, + configuration=configuration, + noise=noise, + input_identity=input_identity, + ) + # The start holds the input, and every frame that saw it has returned but + # this one; the session takes it from the start, so the march holds no + # volume for it (issue #447). + del initial + return self._march( + loaded, progress=progress, progress_period=progress_period + ) + except BaseException: + # A backend that closed itself cannot run another input. + if self._backend.closed: + self._close_held() + raise + finally: + self._lock.release() + + def _takes(self, simulation: "Simulation") -> bool: + """Return whether a simulation is this runner's but for its input.""" + try: + check_derived_problem( + self._problem, + simulation.translate(), + self._request, + simulation._run_request(), + ) + except DriverError as error: + logger.debug("a new backend for this run: %s", error) + return False + return True + + def _run_point( + self, + simulation: "Simulation", + output: Output, + *, + progress: ProgressReporter | ProgressChoice | None, + stop: StopFlag | None, + ) -> None: + """Run a simulation this runner :meth:`_takes`, as its own ``run`` would. + + For a scan's worker, which has built the point's simulation and holds its + record already. Unlike :meth:`run` it honours `stop`, and the handler's record + is the simulation's own. + """ + with self._lock: + simulation._run(output, progress=progress, stop=stop, kept=self._backend) + self.runs += 1 + + def _load( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + *, + output: Output | Path | str | None, + configuration: Mapping[str, object] | None, + noise: NoiseRealisation | Literal["keep"] | None, + input_identity: str | None, + ) -> _LoadedRun: + """Derive one input's run, open its handler and build the input's start. + + The derived simulation and its problem are this frame's alone: they go when it + returns, and the start alone holds the converted launch of a modal route or the + view of the input, until the run's session takes it. + """ + derived = self._simulation.with_input( + _borrowed_input(initial, stated=input_identity is not None), + noise=noise, + configuration=configuration, + input_identity=input_identity, + ) + problem = derived.translate() + request = derived._run_request() + check_derived_problem(self._problem, problem, self._request, request) + settings = self._simulation.settings + with _optmat.material_context(derived._material_log): + metadata = derived.metadata() + handler, _ = _open_output(output, metadata, resume=False) + retention = field_retention(request, settings.fields, settings.field_planes) + monitor = derived._boundary_monitor() + start = Start.entrance( + problem, noise=None if isinstance(noise, str) else _realisation(problem) + ) + return _LoadedRun( + _without_input(problem), + start, + handler, + metadata, + retention, + monitor, + derived._material_log, + ) + + def _march( + self, + loaded: _LoadedRun, + *, + progress: ProgressReporter | ProgressChoice | None, + progress_period: float, + ) -> Output: + """Run the loaded input's save-plane loop and finish its handler. + + - ``progress_period``: seconds between periodic lines, s. + """ + settings = self._simulation.settings + problem, handler = loaded.problem, loaded.handler + with _optmat.material_context(loaded.material_log): + run_problem( + problem, + self._backend, + handler, + start=loaded.start, + metadata=loaded.metadata, + stats_spec=self._request.observation.stats, + progress=progress, + progress_period=progress_period, + resume_history=settings.resume_history, + fields=loaded.retention.fields, + field_planes=loaded.retention.field_planes, + boundary_monitor=loaded.boundary_monitor, + ) + reports = _peak_field_reports(problem, handler, self._simulation.responses) + if reports and isinstance(handler, PlaneStore): + handler.annotate( + PEAK_FIELDS_ANNOTATION, + [cast("JsonValue", report.to_dict()) for report in reports], + ) + self.runs += 1 + return handler + + +_SHARED_LEAVES = ( + "grids", + "linear", + "transform", + "responses", +) +"""The leaves :meth:`Simulation.with_input` shares with the translated problem. + +The route is shared but for a spatial route's transverse design, whose check +:meth:`Simulation.with_input` reruns on the new launch: its variant and its other +leaves are compared beside these (:func:`check_derived_problem`). The statistics are +the request's, shared too.""" + + +def check_derived_problem( + constructed: PropagationProblem, + derived: PropagationProblem, + constructed_request: RunRequest, + derived_request: RunRequest, +) -> None: + """Refuse a derived problem that is not the constructed one but for its input. + + What a :class:`SimulationRunner` checks before loading an input into the backend it + built for `constructed` (SEP 22 section 5.2): the leaves + :meth:`~solprop.driver.simulation.Simulation.with_input` shares are the same + objects, the statistics too, the stepper and the flags, which it rebuilds, agree + field by field (the save planes elementwise), and the launch position and the noise + model are the backend's. + + Parameters + ---------- + constructed : solprop.translate.problem.PropagationProblem + The problem the backend was built for. + derived : solprop.translate.problem.PropagationProblem + The problem of one input. + constructed_request : solprop.translate.run_request.RunRequest + The request the backend was built for. + derived_request : solprop.translate.run_request.RunRequest + The request of the input's run. + + Raises + ------ + DriverError + Naming the first leaf or setting that differs. + + Examples + -------- + >>> from solprop.driver import Simulation + >>> from solprop.driver.runner import check_derived_problem + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, shape="sech", + ... ) + >>> problem = simulation.translate() + >>> derived = simulation.with_input(problem.initial.field0) + >>> check_derived_problem( + ... problem, derived.translate(), simulation._run_request(), + ... derived._run_request(), + ... ) is None + True + """ + for name in _SHARED_LEAVES: + if getattr(derived, name) is not getattr(constructed, name): + msg = ( + f"the input's problem does not share the runner's {name}: a runner " + "steps one translated problem and changes only its input" + ) + raise DriverError(msg) + if derived_request.observation.stats is not constructed_request.observation.stats: + msg = ( + "the input's problem does not share the runner's stats_spec: a runner " + "steps one translated problem and changes only its input" + ) + raise DriverError(msg) + if ( + type(derived.route) is not type(constructed.route) + or tdse_of(derived.route) is not tdse_of(constructed.route) + or flux_of(derived.route) is not flux_of(constructed.route) + ): + msg = ( + "the input's problem does not share the runner's route: a runner steps " + "one translated problem and changes only its input" + ) + raise DriverError(msg) + if derived.initial.z != constructed.initial.z: + msg = ( + f"the input is launched at z = {derived.initial.z!r} m and the runner's " + f"problem at {constructed.initial.z!r} m: the save planes and the " + "schedule's cursor belong to the problem the backend was built for, so a " + "different initial_z needs its own simulation" + ) + raise DriverError(msg) + if derived.initial.noise_model != constructed.initial.noise_model: + msg = ( + f"the input carries the noise model {derived.initial.noise_model!r} and " + f"the runner's problem {constructed.initial.noise_model!r}: the backend " + "carries the model it was built with, and would drop or misapply another" + ) + raise DriverError(msg) + for record, built, name in ( + (derived_request.stepper, constructed_request.stepper, "stepper"), + (derived.flags, constructed.flags, "flags"), + ): + for entry in dataclasses.fields(record): + if not _same_setting( + getattr(built, entry.name), getattr(record, entry.name) + ): + msg = ( + f"the input's {name}.{entry.name} differs from the runner's: a " + "runner steps one translated problem and changes only its input" + ) + raise DriverError(msg) + + +def _peak_field_reports( + problem: PropagationProblem, + output: Output, + responses: Sequence[object], +) -> tuple[PeakFieldReport, ...]: + """Report the largest field each optmat-rate population saw, after a run. + + The populations' peak rates at the save planes are read back from the run's + output -- ``species_peak_ionisation_rate``, or ``peak_ionisation_rate`` for one + population -- and inverted through each rate + (:func:`solprop.nonlinear.optmat_rates.peak_field_report`). + + - Returns: one per population with an optmat rate; none when the run carries no such + rate or recorded no peak rate, which is logged. + """ + labels = problem.responses.labels + rates = [ + ( + one.species + or (labels[0] if len(labels) == 1 else str(getattr(one.gas, "name", ""))), + one.rate, + ) + for one in responses + if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) + and isinstance(one.rate, OptmatRate) + ] + if not rates: + return () + if isinstance(output, HDF5Output) and output.closed: + with HDF5Output(output.path, readonly=True) as reader: + return _peak_field_reports(problem, reader, responses) + if "species_peak_ionisation_rate" in output.stat_names: + rows = np.asarray(output["stats/species_peak_ionisation_rate"]) + peaks = np.max(rows, axis=0) + elif "peak_ionisation_rate" in output.stat_names and len(labels) == 1: + rows = np.asarray(output["stats/peak_ionisation_rate"]) + peaks = np.array([np.max(rows)]) + else: + logger.info( + "the run recorded no peak ionisation rate, so the largest field its " + "optmat rates saw is not reported" + ) + return () + envelope = problem.responses.envelope_plasma + cycles = ( + {} + if envelope is None + else {channel.label: channel.cycle for channel in envelope.channels} + ) + reports = [] + for label, rate in rates: + report = peak_field_report( + rate, + float(peaks[labels.index(label)]), + label=label, + # An envelope's statistic is its own cycle average of its table, at + # linear polarisation the cosine moment alone. + average=None + if label not in cycles + else functools.partial(_linear_moment, cycles[label]), + ) + if report.reached_maximum: + _optmat.warn_once( + ("rate-reached-maximum", rate.material_records[0].digest), + f"{label}: the run reached the maximum field " + f"{report.maximum_field:.4g} V/m of optmat's {report.model}, above " + "which its rate is held at its value there; the largest field is at " + "least that", + MaterialRangeWarning, + ) + logger.info( + "%s: largest field %s V/m (maximum %.4g V/m); rate-weighted energy there " + "%s against the constant %.6g J", + label, + "none" if report.largest_field is None else f"{report.largest_field:.4g}", + report.maximum_field, + "none" + if report.departure is None + else f"departs by {report.departure:+.3e}", + report.constant_energy, + ) + reports.append(report) + return tuple(reports) + + +def _linear_moment(cycle: CycleIntegralData, amplitude: float) -> float: + """Return an envelope run's cycle-averaged rate at a linear amplitude. + + - ``amplitude``: envelope amplitude in V/m. + - Returns: the rate in s^-1 its statistic records there. + """ + return float(cycle_moments(amplitude, 0.0, cycle)[0]) diff --git a/src/solprop/driver/settings.py b/src/solprop/driver/settings.py new file mode 100644 index 00000000..1cf611fa --- /dev/null +++ b/src/solprop/driver/settings.py @@ -0,0 +1,1001 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""How a run is stepped, what it records and which backend executes it. + +:class:`SimulationSettings` and the checks that admit it, and the settings defaults a +keyword route applies: its save planes, its controller and its absorbing boundaries +(SEP 21). The manual page is ``docs/guide/setup/running.md``. +""" + +import dataclasses +import warnings +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Literal, SupportsIndex, cast + +import numpy as np +from jaxtyping import Float + +from solprop._display import Displayed, engineering, line, summarise +from solprop.adiabatic import AdiabaticAccuracy +from solprop.backends.registry import resolve_name +from solprop.backends.session import ExecutionOptions, positive_count +from solprop.boundaries import Absorber, SpatialAbsorber, no_absorber +from solprop.boundaries import absorber as collar +from solprop.driver.defaults import ( + default_controller, + default_integrator, + default_rtol, + default_scheme, +) +from solprop.exceptions import BackendError, DriverError, GridError, SolpropWarning +from solprop.grid import TimeGrid +from solprop.modes.quadrature import TransverseQuadrature +from solprop.monitor import DEFAULT_BOUNDARY_CHECK, BoundaryCheck +from solprop.nonlinear.tdse import TDSEExecution +from solprop.output.schema import ( + DefaultsResolution, + FieldsChoice, + field_retention_refusal, +) +from solprop.profiles import ZProfile +from solprop.stats import StatsSpec +from solprop.stepper.controller import PIController +from solprop.stepper.low_storage import LowStorageRungeKutta +from solprop.stepper.multistep import Multistep +from solprop.stepper.runge_kutta import RungeKutta +from solprop.stepper.schedule import FixedSchedule +from solprop.translate.masks import AbsorberScheme +from solprop.translate.problem import PropagatorMode + +__all__ = [ + "DEFAULT_N_SAVE", + "LOOSE_ADAMS_RTOL", + "LOW_STORAGE_BACKENDS", + "NoiseSetting", + "SimulationSettings", + "default_collar", +] + +LOOSE_ADAMS_RTOL = 5e-2 +"""The ``rtol`` from which an adaptive Adams run is warned on. + +An error of five per cent or more allowed per step compounds through the method's +history, and its predictor-corrector estimate is unreliable there +(``docs/guide/numerics/choosing-a-stepper.md``). +""" + +DEFAULT_N_SAVE = 201 +"""Planes a GNLSE run stores, counting the one at ``z = 0``.""" + +_RECIPE_EXECUTION = ("fields", "field_planes", "resume_history") +"""The settings a recipe's ``[execution]`` table holds: a change of any other one drops +the recipe, which would no longer describe the run (SEP 41, S2).""" + + +def _held_budget(value: SupportsIndex | None) -> int | None: + """Return a settings node budget as an ``int``, refused before translation. + + Any integral budget, a NumPy integer among them, is held as an ``int``, and a + budget that is not one is refused when the settings are made, rather than when the + backend is built after the output has opened. + + - ``value``: the budget given, bytes. + - Returns: the budget, or ``None`` for the backend's automatic choice. + """ + if value is None: + return None + try: + return positive_count("node_budget", value) + except BackendError as error: + raise DriverError(str(error), parameter="node_budget") from None + + +_EXECUTION_SETTINGS = ( + "backend", + "node_budget", + "resume_history", + "tdse_execution", + "fields", + "field_planes", + "boundary_check", +) +"""The settings read when a run executes, not when it is translated.""" + +NoiseSetting = Literal["default", "input", "modified"] | None +"""What a constructor's ``noise`` keyword takes (SEP 37). + +A model, ``None`` for a noiseless run, or ``"default"``, the keyword's default, which +resolves to ``"input"`` wherever the run can carry noise and to none where it cannot, +recording which in the run's :class:`~solprop.output.schema.NoiseResolution`. +""" + + +class _Defaulted(str): + """A setting's value that a default rule chose rather than the caller. + + A copy made with :func:`dataclasses.replace` carries the marker with the value, so + the copy resolves the rule again for its own route or planes (the collar scheme), + while a value the caller names, even an equal one, is a plain string and stays as + given. + """ + + __slots__ = () + + +@dataclass(frozen=True, eq=False) +class SimulationSettings(Displayed): + """How a run is stepped, what it records and which backend executes it. + + Everything that is *not* a physics object, in one group, so that a simulation can be + constructed from the physics and this. Equality is by identity (``eq=False``) + because ``save_planes`` is an array. + + Attributes + ---------- + controller : solprop.stepper.controller.PIController or None + Tolerances, safety and gains; the tolerances and the norm choice live here and + nowhere else (``docs/dev/kernel-contract.md`` section 7). ``None``, the default, + takes the route's from :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` (SEP 37) + when an interface builds the run: the + :data:`~solprop.driver.defaults.SPATIAL_RTOL` and + :data:`~solprop.driver.defaults.SPATIAL_ATOL` of + :meth:`~solprop.driver.simulation.Simulation.from_radial` and + :meth:`~solprop.driver.simulation.Simulation.from_planar`, the + :data:`~solprop.driver.defaults.FREE_RTOL` and + :data:`~solprop.driver.defaults.FREE_ATOL` of + :meth:`~solprop.driver.simulation.Simulation.from_free`. An explicit controller + always wins, and a default copied into another route takes that route's default. + The other fields are keyword-only. + save_planes : numpy.ndarray + Positions to save at, m, increasing and positive, shape ``(n_z,)``. The plane at + ``z = 0`` is saved as well and is not listed here. A sequence is adopted as a + read-only float64 array, so that two runs specified the same way record the same + planes whichever type they passed and therefore hash alike. + dz_max : float + Cap on the controller's proposal, m. + dz_init : float + First step, m. + dz_min : float or None + Floor on the proposal, m; ``None`` takes + :func:`solprop.stepper.controller.default_dz_min` of the run length. A proposal + below it raises rather than being clamped silently. + stretch : float or None + Landing stretch factor; ``None`` takes + :data:`solprop.translate.problem.DEFAULT_STRETCH`. + auto_init : bool + Whether the start-up rule takes the step after the first from the first step's + own error estimate, rather than growing from `dz_init` at the growth cap (SEP + 14). The convenience constructors set it when no `dz_init` is given. It is off + for a coupled flow and a reject-free controller. + interval_cap : bool or None + Whether a step is capped at the width of its operator-table interval. ``None`` + caps it only for a coupled flow or a reject-free controller (SEP 14). + interpolate_planes : bool + Observe the save planes inside accepted steps instead of landing on them (SEP + 14 phase 3): through the Adams march's own polynomial or, with + ``RungeKutta()``, DOPRI5's continuous extension. Every plane but the run's end + and a hard kink is then interpolated. Refused beside a coupled flow, the + per-step temporal collar and the ``modified`` noise. + step_planes : int + A positive ``k`` also saves the end of every ``k``-th accepted step, the + march's own positions, beside the requested planes (SEP 14 phase 3); ``0`` + saves the requested planes only. Not in scans. + integrator : Multistep, RungeKutta, LowStorageRungeKutta or None + The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). + ``None``, the default, takes ``Multistep()``, or ``RungeKutta()`` where the + interface's builder sets it + (:meth:`~solprop.driver.simulation.Simulation.from_gnlse`, + :meth:`~solprop.driver.simulation.Simulation.from_waveguide` and + :meth:`~solprop.driver.simulation.Simulation.from_free`; + ``docs/guide/numerics/choosing-a-stepper.md``); the stored value is the resolved + method. ``Multistep()`` is the low-call exponential Adams method, which + ``numpy_ref`` and ``native`` execute for supported configurations; + ``RungeKutta()`` is the interaction-picture DOPRI5 march, which every backend + executes and which holds a third of the Adams history's memory. + ``LowStorageRungeKutta()`` replays `schedule` with RK4 in three registers and no + error estimate (SEP 24); every backend executes it + (:data:`LOW_STORAGE_BACKENDS`), and ``"auto"`` resolves as for any integrator. + schedule : solprop.stepper.schedule.FixedSchedule or None + A fixed schedule to replay instead of stepping adaptively + (``docs/dev/stepper.md`` section 8). Its edges must contain every save plane, + which :meth:`solprop.stepper.schedule.FixedSchedule.uniform` guarantees. + propagator_mode : {'auto', 'tabulate', 'recompute'} + Whether the backend tabulates the stage propagators + (``docs/dev/kernel-contract.md``). + density : solprop.profiles.ZProfile or float or None + Number density along z in m^-3 that multiplies the compiled responses; ``None`` + takes the mode's own path. The GNLSE passes 1.0 + (``docs/guide/waveguides/gnlse.md``). + Refused beside a mixture and beside a gas medium, which carry their own. + shock : bool + Keep the ``omega`` dependence of the normalisation prefactor, which is the + optical shock term. + prefactor_index : float or None + Use this constant in the prefactor and power weight; None uses Re n_eff. + The GNLSE passes 1.0, leaving its power normalisation and Phi unchanged. + absorber_scheme : {'nonlinear', 'split_step'} or None + How the temporal collar is applied (``docs/guide/numerics/boundaries.md``). + ``None``, the default, is the route's: ``"split_step"`` on the radial, + free-space and planar routes unless planes are interpolated (SEP 21), and + ``"nonlinear"`` elsewhere. ``"nonlinear"`` adds ``-alpha(t) E(t) / 2`` inside + every stage; ``"split_step"`` multiplies the accepted state by + ``exp(-alpha(t) h / 2)`` once per step, a Lie splitting, first order wherever + the collar does any work and unconditionally stable. + absorber : solprop.boundaries.Absorber or None + The collar rates. Its spectral part folds into the linear operator and its + temporal part crosses the seam as a rate + (``docs/guide/numerics/boundaries.md``). + quadrature : solprop.modes.quadrature.TransverseQuadrature or None + The fixed transverse rule a multimode run integrates the nonlinear polarisation + on (``docs/guide/waveguides/mode-sets.md``). ``None`` builds the mode-averaged + single-node transform, which needs exactly one mode. + components : sequence of str or None + Which transverse Cartesian components the multimode transform resolves at each + node, a non-empty subset of ``("x", "y")``; ``None`` takes the union of + the modes' nonzero components at the entrance (``("x",)`` for HE_1m modes + at ``phi = 0``; ``docs/dev/conventions.md`` section 5). Refused without a + `quadrature`. + ionisation_field_range : tuple of float or None + Lowest and highest field amplitude in V/m the compiled ionisation-rate table + has to cover. Required beside a plasma response whose rate is not already a + table; the table holds its last value above the upper limit, so one set too + low is a silent clip. + node_budget : int or None + Bytes of node-chunk buffers a response pass may hold + (``docs/dev/architecture.md`` section 13). An execution setting, outside the + resume key: the backend plans its node chunk from it when it is prepared, and + it may change after translation. ``None``, the default, leaves the choice to + the backend, which takes 1 GiB on a free-space, line or planar grid and 64 MiB + otherwise (:data:`~solprop.backends.node_chunk.DEFAULT_SPATIAL_NODE_BUDGET`, + :data:`~solprop.backends.node_chunk.DEFAULT_NODE_BUDGET`); an explicit budget + is authoritative. + stats : solprop.stats.StatsSpec or None + Which statistics to collect at every saved plane; ``None`` collects none. + polarisation_taper : bool + Whether the spectral collar tapers the nonlinear polarisation as well as + absorbing the field through the linear operator. Default ``False``, which is + :func:`solprop.translate.problem.assemble`'s: the taper costs a systematic + error of the size of the field's content in the collar, and the rate absorber + acts either way (``docs/guide/numerics/boundaries.md``). + backend : str + Registry name of the backend to execute the run. The default ``"auto"`` + resolves to ``"native"`` at settings construction, and refuses if it isn't + installed or this processor is below its baseline. + The stored name is concrete, including in scan workers. A backend's import or + construction errors never trigger a fallback. + resume_history : bool or None + Whether the output keeps the Adams march's state at every save plane and + terminal checkpoint, so that a resume continues the history -- its samples, its + order and its proposed step -- as the uninterrupted run would, instead of + starting it afresh at order one. ``None``, the default, keeps it while it is at + most :data:`solprop.driver.run.MARCH_SNAPSHOT_AUTO_BYTES`; it is outside the + resume key. Every backend's fused and exponential Adams engines, the native + backend's resident one included, export and install their history. + fields : {'all', 'planes', 'none'} + Which saved planes keep their field (SEP 22): every one, the default; those + of `field_planes`; or none, for a run wanted as statistics alone. A plane + without its field stores no field and no noise phase, and an in-memory output + is allocated for the kept fields only; the march is the same. A + terminal checkpoint carries the field anyway, so a stopped run resumes, but a + file whose last regular plane kept no field resumes only from one. Outside + the resume key, like `resume_history`: it changes what is kept, not what is + computed. In a scan, :func:`~solprop.driver.scan.run_scan`'s own `fields` + decide, and a builder that sets this is refused. Beside + ``LowStorageRungeKutta()``, a step abandoned in flight leaves no checkpoint, so + a file resumes from the last plane that kept its field. + field_planes : sequence of int + Saved-plane indices for ``fields='planes'``, the entrance as 0 and negative + from the end; empty otherwise. Refused beside `step_planes`, whose plane count + is not known in advance. + evanescent_rate : float or None + The spatial operator's evanescent field attenuation cap in m^-1, positive and + finite (SEP 22 section 4.4). ``None``, the default, keeps + :data:`solprop.spatial.EVANESCENT_RATE`; + :func:`solprop.spatial.physical_evanescent_rate` gives the rate at which the + grid's non-propagating content decays physically. Refused without a spatial + grid; a replayed step it would overflow is refused at translation. + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None + The transverse boundary of a free-space run (SEP 40). ``None``, the default, + is ``SpatialAbsorber(real_space=False)``: a wall or a wrap at the grid's + outer size and a safety ramp on its outer wavenumbers, folded into the + operator as an exact diagonal rate. A + :class:`~solprop.boundaries.SpatialAbsorber` asks for a real-space collar + too, in the room the grid has outside its requested size + (``collar_fraction``). ``False`` carries no ramp either; the boundary + monitor still runs. A specification is refused on modal runs. + adiabatic_accuracy : solprop.adiabatic.AdiabaticAccuracy or None + The forward-field and reflected-energy budgets a radial medium varying + along z must meet (SEP 34 section 4.5). ``None``, the default, is + ``AdiabaticAccuracy()``, both 1e-4; a path exceeding them is refused. + tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None + Explicit atomic runtime resources for a TDSE response. Requires an explicit + ``backend="numpy_ref"`` and a Multistep integrator. Execution resources are + outside the scientific resume identity; attempted-call budgets are cumulative. + boundary_check : solprop.monitor.BoundaryCheck or None + How the run watches its time window's edges (SEP 21): guard and collar + occupancy at the launch and every committed step, a warning and then a stop + when light reaches the edge, and the energy the temporal collar removed at the + end. The default :class:`~solprop.monitor.BoundaryCheck` warns at a field-error + budget of 1e-3 and stops at ten times it; ``None`` watches nothing. + + Raises + ------ + DriverError + If the save planes are empty, not increasing, or not positive. The same check + runs again in :func:`solprop.translate.problem.assemble`; here it fires before a + run has built anything. Also if the field retention is not valid, asks for + ``fields='planes'`` beside `step_planes`. + + Examples + -------- + >>> from solprop.driver import SimulationSettings + >>> from solprop.stepper import PIController + >>> settings = SimulationSettings( + ... controller=PIController(rtol=1e-8, atol=1e-14), + ... save_planes=[0.5, 1.0], dz_max=0.1, dz_init=1e-3, + ... backend="numpy_ref", + ... ) + >>> settings.save_planes.tolist(), settings.backend + ([0.5, 1.0], 'numpy_ref') + """ + + controller: PIController | None = None + _: dataclasses.KW_ONLY + save_planes: Float[np.ndarray, " n_z"] | Sequence[float] + dz_max: float + dz_init: float + dz_min: float | None = None + stretch: float | None = None + auto_init: bool = False + interval_cap: bool | None = None + interpolate_planes: bool = False + step_planes: int = 0 + schedule: FixedSchedule | None = None + propagator_mode: PropagatorMode = "auto" + density: ZProfile | float | None = None + shock: bool = True + prefactor_index: float | None = None + absorber: Absorber | None = None + absorber_scheme: AbsorberScheme | None = None + quadrature: TransverseQuadrature | None = None + components: Sequence[str] | None = None + ionisation_field_range: tuple[float, float] | None = None + node_budget: int | SupportsIndex | None = None + stats: StatsSpec | None = None + polarisation_taper: bool = False + backend: str = "auto" + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None + resume_history: bool | None = None + tdse_execution: TDSEExecution | None = None + fields: FieldsChoice = "all" + field_planes: Sequence[int] = () + evanescent_rate: float | None = None + spatial_absorber: SpatialAbsorber | Literal[False] | None = None + adiabatic_accuracy: AdiabaticAccuracy | None = None + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK + _default_integrator: Multistep | RungeKutta | None = dataclasses.field( + default=None, repr=False, compare=False + ) + """The default the settings chose for an unnamed integrator. A copy made with + :func:`dataclasses.replace` carries it with the integrator, so the copy still knows + the caller named none; any other integrator counts as named.""" + _route: str | None = dataclasses.field(default=None, repr=False, compare=False) + """The row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the interface that + built the settings reads, or ``None`` for settings built by hand. A copy made with + :func:`dataclasses.replace` carries it, so a copy takes the route's defaults + again, and the route's choice never counts as named.""" + _default_controller: PIController | None = dataclasses.field( + default=None, repr=False, compare=False + ) + """The controller the settings took from the route's defaults. A copy carries it + with the controller, so the copy still knows the caller gave none; any other + controller counts as given.""" + # Whether the caller named the integrator, set per instance in __post_init__. + integrator_named = False + # Whether the controller is the route's default, set per instance in __post_init__. + controller_defaulted = False + + def _planes_text(self) -> str: + """Return the save planes summarised, for printing.""" + planes = np.asarray(self.save_planes, dtype=float) + return summarise(planes, "m").replace("values", "planes") + + def __repr__(self) -> str: + """Return a one-line description with the save planes summarised, not dumped. + + Returns + ------- + str + For example ``SimulationSettings(<201 planes, 0 m to 1.5 m>, rtol=1e-06, + backend=auto)``. + """ + return line( + "SimulationSettings", + [ + ("", self._planes_text()), + *( + [("", "interface default tolerances")] + if self.controller is None + else [ + ("rtol", f"{self.controller.rtol:g}"), + ("atol", f"{self.controller.atol:g}"), + ] + ), + ("backend", self.backend), + ], + ) + + def _default_text(self, text: str) -> str: + """Mark the integrator's name when it is the route's default. + + - Returns: `text`, then ``"(default)"`` or ``"(gnlse default)"`` where no + integrator was named. + """ + if self.integrator_named: + return text + return f"{text} ({'' if self._route is None else self._route + ' '}default)" + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + schedule = "adaptive" if self.schedule is None else type(self.schedule).__name__ + return "SimulationSettings", [ + ("save planes", self._planes_text()), + ("integrator", self._default_text(type(self.integrator).__name__)), + ( + "tolerances", + "the interface's default" + if self.controller is None + else f"rtol {self.controller.rtol:g}, atol {self.controller.atol:g}" + + ( + f" ({self._route} default)" + if self.controller_defaulted and self._route is not None + else "" + ), + ), + ("steps", f"{schedule}, dz_max {engineering(self.dz_max, 'm')}"), + ("shock term", self.shock), + ( + "absorber", + ( + f"{_scheme(self)} scheme" + + ("" if self.absorber_scheme_named else " (default)") + ) + if self.absorber is not None + else "none", + ), + ("statistics", "none" if self.stats is None else type(self.stats).__name__), + ("backend", self.backend), + ] + + def __post_init__(self) -> None: + """Adopt a read-only plane array and check it. + + Raises + ------ + DriverError + If the planes are empty, not increasing, or not positive. + """ + planes = np.ascontiguousarray(self.save_planes, dtype=np.float64).reshape(-1) + if ( + planes.size == 0 + or not np.all(np.isfinite(planes)) + or not np.all(np.diff(planes) > 0.0) + or planes[0] <= 0.0 + ): + msg = ( + f"save_planes must be positive and increasing, got {planes}; the plane " + "at z = 0 is saved anyway and is not one of them" + ) + raise DriverError(msg, parameter="save_planes") + planes.setflags(write=False) + object.__setattr__(self, "save_planes", planes) + refusal = field_retention_refusal(self.fields, self.field_planes) + if refusal is not None: + raise DriverError(refusal, parameter=("fields", "field_planes")) + object.__setattr__(self, "field_planes", tuple(self.field_planes)) + object.__setattr__(self, "node_budget", _held_budget(self.node_budget)) + self._resolve_defaults() + if self.fields == "planes" and self.step_planes: + msg = ( + "fields='planes' names saved planes by index, and step_planes saves " + "the march's own steps as well, so the plane count is not known in " + "advance; keep fields='all' or 'none' beside step_planes" + ) + raise DriverError( + msg, + parameter=("fields", "step_planes"), + rule="fields-planes-step-planes", + ) + if self.tdse_execution is not None: + _admit_tdse_execution(self) + # The annotation refuses another integrator first wherever the annotations are + # checked at run time, as in the test suite. + if not isinstance( + self.integrator, Multistep | RungeKutta | LowStorageRungeKutta + ): # pragma: no cover + msg = ( + "integrator must be Multistep(), RungeKutta() or " + f"LowStorageRungeKutta(), got {self.integrator!r}" + ) + raise DriverError(msg, parameter="integrator") + if isinstance(self.integrator, LowStorageRungeKutta): + backend = _admit_low_storage(self.integrator, self.backend, self) + object.__setattr__(self, "backend", backend) + # An Adams integrator resolves "auto" as the Runge-Kutta march does: every + # backend steps the exponential formulation on every route, and the + # interaction one on every route but a coupled flow, which it refuses by name + # (SEP 16 phases 3 and 5). + object.__setattr__(self, "backend", resolve_name(self.backend)) + if isinstance(self.integrator, Multistep): + _admit_integrator(self.integrator, self.backend) + rtol = getattr(self.controller, "rtol", None) + if self.schedule is None and rtol is not None and rtol >= LOOSE_ADAMS_RTOL: + msg = ( + f"rtol={rtol:g} is loose for the Adams method: an error of this " + "size allowed per step compounds through its history, and its " + "error estimate is unreliable there. Take rtol at most 1e-2, or " + "integrator=RungeKutta() for a rough look" + ) + warnings.warn(msg, SolpropWarning, stacklevel=3) + + def _resolve_defaults(self) -> None: + """Take the route's default for each of the stepping choices the caller left. + + The integrator, the controller and the collar scheme each come from + :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` when not given. A default + carried by a copy is resolved again, so it follows the copy's route and + planes; a value the caller gave is kept. + """ + carried = ( + self.integrator is not None and self.integrator is self._default_integrator + ) + object.__setattr__( + self, "integrator_named", self.integrator is not None and not carried + ) + if not self.integrator_named: + default = default_integrator(self._route) + if carried and type(self.integrator) is type(default): + default = self.integrator + object.__setattr__(self, "integrator", default) + object.__setattr__(self, "_default_integrator", default) + controller = self.controller + defaulted = controller is not None and controller is self._default_controller + if controller is None and self._route is not None: + controller = default_controller(self._route) + defaulted = True + elif defaulted and controller is not None: + # A default copied into another route takes that one's tolerance, + # keeping the absolute tolerance the route's builder chose. + rtol = ( + default_rtol(self._route) + if self._route is not None + else controller.rtol + ) + if rtol != controller.rtol: + controller = dataclasses.replace(controller, rtol=rtol) + object.__setattr__(self, "controller", controller) + object.__setattr__(self, "controller_defaulted", defaulted) + if defaulted: + object.__setattr__(self, "_default_controller", controller) + if self.absorber_scheme is None or isinstance(self.absorber_scheme, _Defaulted): + scheme = default_scheme( + self._route, interpolate_planes=self.interpolate_planes + ) + object.__setattr__(self, "absorber_scheme", _Defaulted(scheme)) + + @property + def absorber_scheme_named(self) -> bool: # numpydoc ignore=RT01 + """Whether the caller named the collar scheme, rather than taking the route's. + + False when :attr:`absorber_scheme` is the route's default. + """ + return not isinstance(self.absorber_scheme, _Defaulted) + + def execution_options(self) -> ExecutionOptions: + """Return the execution options a backend is prepared with (SEP 47). + + The node budget is an execution choice, not part of the scientific problem: + the translation records none, and the backend plans its node chunk from it + when it is prepared (:func:`solprop.backends.node_chunk.plan_node_chunk`). + + Returns + ------- + solprop.backends.session.ExecutionOptions + The explicit :attr:`node_budget`, or the backend's automatic choice. + + Examples + -------- + >>> from solprop.driver import SimulationSettings + >>> settings = SimulationSettings( + ... save_planes=[1.0], dz_max=0.1, dz_init=0.01, node_budget=2**26 + ... ) + >>> settings.execution_options().node_budget + 67108864 + """ + return ExecutionOptions(node_budget=self.node_budget) + + def defaults_resolution(self) -> DefaultsResolution: + """Return which stepping choices took the route's default (#87, SEP 41). + + Returns + ------- + solprop.output.schema.DefaultsResolution + The route, and which of the integrator, the relative tolerance and the + collar scheme came from its row of the defaults table. + + Examples + -------- + >>> from solprop.driver import SimulationSettings + >>> settings = SimulationSettings( + ... save_planes=[1.0], dz_max=0.1, dz_init=0.01, backend="numpy_ref" + ... ) + >>> settings.defaults_resolution() + DefaultsResolution(route=None, defaulted=('integrator', 'absorber_scheme')) + """ + defaulted = ( + *(() if self.integrator_named else ("integrator",)), + *(("rtol",) if self.controller_defaulted else ()), + *(() if self.absorber_scheme_named else ("absorber_scheme",)), + ) + return DefaultsResolution(self._route, defaulted) + + +def _admit_tdse_execution(settings: SimulationSettings) -> None: + """Refuse TDSE execution resources beside settings a TDSE run cannot step with. + + Each refusal names the setting at fault, in the order a caller would fix them: + the backend, then the integrator. + """ + if not isinstance(settings.tdse_execution, TDSEExecution): + msg = ( + "tdse_execution must be a TDSEExecution, got " + f"{type(settings.tdse_execution).__name__}" + ) + raise DriverError(msg, parameter="tdse_execution") + if settings.backend != "numpy_ref": + msg = ( + f"tdse_execution requires an explicit backend='numpy_ref', the one backend " + f"that runs the TDSE response; got backend={settings.backend!r}" + ) + raise DriverError( + msg, parameter=("tdse_execution", "backend"), rule="tdse-numpy-ref" + ) + if not isinstance(settings.integrator, Multistep): + msg = ( + "tdse_execution requires integrator=Multistep(), the Adams method the TDSE " + f"response steps with; got {type(settings.integrator).__name__}()" + ) + raise DriverError( + msg, parameter=("tdse_execution", "integrator"), rule="tdse-multistep" + ) + + +def _quiet_collar(settings: SimulationSettings) -> bool: + """Return whether a run's settings opt in to the quiet collar (ADR-057). + + - Returns: True when the boundary check asks for it. + """ + check = settings.boundary_check + return check is not None and check.quiet_collar + + +def _keyword_controller( + route: str, rtol: float | None, atol: float +) -> tuple[PIController, PIController | None]: + """Return a keyword interface's controller, and it again if its rtol is default. + + - ``route``: the row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the + interface reads. + - ``rtol``: the caller's relative tolerance, which is never raised silently; + ``None`` takes the route's (:func:`~solprop.driver.defaults.default_rtol`). + - Returns: the controller, and the same object where `rtol` was the route's default + so that the settings record the default, else ``None``. + """ + if rtol is not None: + return PIController(rtol=rtol, atol=atol), None + controller = PIController(rtol=default_rtol(route), atol=atol) + return controller, controller + + +LOW_STORAGE_BACKENDS = ("native", "numpy_ref") +"""The backends that execute :class:`~solprop.stepper.low_storage.LowStorageRungeKutta`. + +``numpy_ref`` from SEP 24's phase 1 and solprop-native from phase 3, on every route the +translation admits the march on; ``backend="auto"`` resolves as for every other +integrator (:func:`solprop.backends.registry.resolve_name`: the native backend). +""" + + +def _admit_low_storage( + integrator: LowStorageRungeKutta, backend: str, settings: SimulationSettings +) -> str: + """Refuse what the low-storage march cannot run, before translation (SEP 24 5). + + The march replays a prescribed schedule, lands on its edges and accepts every + step, so it needs a schedule and has no interpolated planes. Every registered + backend executes it (:data:`LOW_STORAGE_BACKENDS`), so the backend passes as asked. + """ + if settings.schedule is None: + msg = ( + f"the {integrator.name} march replays a prescribed schedule; pass " + "schedule=FixedSchedule.uniform(...), or step adaptively with RungeKutta()" + ) + raise DriverError( + msg, parameter=("integrator", "schedule"), rule="low-storage-needs-schedule" + ) + if settings.interpolate_planes: + msg = ( + f"the {integrator.name} march lands on its schedule's edges and has no " + "dense output; drop interpolate_planes" + ) + raise DriverError( + msg, + parameter=("integrator", "interpolate_planes"), + rule="low-storage-no-interpolation", + ) + return backend + + +def _admit_integrator(integrator: Multistep, backend: str) -> None: + """Refuse an Adams integrator on a backend that does not execute it. + + Refused here, before translation, so that no backend -- in particular a + separately built one reading the contract by name -- can silently step the problem + with its Runge-Kutta march instead (SEP 13 section 6.1). + """ + if backend not in ("auto", "numpy_ref", "native"): + msg = ( + f"the {integrator.name} integrator is implemented on numpy_ref and native " + f"only, and backend {backend!r} would step the problem with its " + "Runge-Kutta march; pass one of those or 'auto', or " + "integrator=RungeKutta()" + ) + raise DriverError( + msg, parameter=("integrator", "backend"), rule="integrator-backends" + ) + + +def _same_setting(old: object, new: object) -> bool: + """Return whether two values of one settings field are the same setting. + + - Returns: True for the same object, equal arrays, or values that compare equal; an + object compared by identity (``eq=False``) is the same only as itself, which is + what :func:`dataclasses.replace` keeps for every field it is not given. + """ + if old is new: + return True + if isinstance(old, np.ndarray) or isinstance(new, np.ndarray): + # The other side is an array-like setting (save_planes given as a list). + left = np.asarray(cast("np.ndarray", old)) + right = np.asarray(cast("np.ndarray", new)) + return left.shape == right.shape and bool(np.array_equal(left, right)) + try: + return bool(old == new) + except ValueError: + # A container of arrays has no single truth value; it is not provably the same. + return False + + +def _save_planes( + length: float, + save_planes: Sequence[float] | np.ndarray | None, + n_save: int | None, +) -> Float[np.ndarray, " n_z"]: + """Return the save planes a keyword interface asks for. + + - ``length``: fibre length, m. + - ``save_planes``: positions given by the caller, m. + - Returns: the planes after ``z = 0``, m, shape ``(n_z,)``. + """ + if save_planes is not None: + if n_save is not None: + msg = "give save_planes or n_save, not both" + raise DriverError( + msg, parameter=("save_planes", "n_save"), rule="save-planes-or-n-save" + ) + return np.ascontiguousarray(save_planes, dtype=np.float64).reshape(-1) + count = DEFAULT_N_SAVE if n_save is None else n_save + if count < 2: + msg = ( + f"n_save counts the plane at z = 0, so it must be at least 2, got {count}; " + "a run always stores the entrance" + ) + raise DriverError(msg, parameter="n_save") + return np.linspace(0.0, length, count)[1:] + + +def _scheme(settings: SimulationSettings) -> AbsorberScheme: + """Return the collar scheme the settings resolve to.""" + return cast("AbsorberScheme", str(settings.absorber_scheme)) + + +def _spatial_settings(settings: SimulationSettings, route: str) -> SimulationSettings: + """Give a spatial run's settings the route's defaults (SEP 37, SEP 21). + + - ``route``: the interface they are for. + - Returns: `settings`, reading the route's row of + :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` for whatever the caller left: the + controller (:data:`~solprop.driver.defaults.SPATIAL_RTOL` for radial and planar + runs, :data:`~solprop.driver.defaults.FREE_RTOL` for Cartesian and line ones), the + integrator (``RungeKutta()`` in free space, whose Adams + history would be too large) and the collar scheme (``"split_step"`` unless planes + are interpolated). Settings that already belong to a route, this one or another + (settings copied from a modal run, for a comparison), keep what that route + resolved and are returned as they are. + """ + if settings._route is not None: + return settings + return dataclasses.replace(settings, _route=route) + + +def _with_boundary( + settings: SimulationSettings, + grid: TimeGrid, + absorber: Absorber | Literal[False] | None, + scheme: AbsorberScheme | None, +) -> SimulationSettings: + """Apply a spatial interface's absorber keywords to its settings (SEP 21 section 5). + + - ``absorber``: the collar given; ``False`` zeroes the temporal and spectral rates + of the settings' absorber and keeps its lengths (without one it builds the hard + mask alone); ``None`` leaves the settings' own. The transverse boundary is + ``spatial_absorber``'s (SEP 40). + - ``scheme``: the temporal scheme given; ``None`` leaves the settings' own. + """ + changes: dict[str, object] = {} + if absorber is False: + if scheme is not None: + msg = ( + "absorber=False switches the temporal collar off, so there is nothing " + f"for absorber_scheme={scheme!r} to apply; give one or the other" + ) + raise DriverError(msg) + current = settings.absorber + changes["absorber"] = ( + no_absorber(grid) + if current is None + else dataclasses.replace( + current, + spectral_rate=np.zeros_like(current.spectral_rate), + user_rate=None, + temporal_rate=np.zeros(grid.n_t), + temporal_rate_fine=np.zeros(grid.n_to), + temporal=False, + ) + ) + elif absorber is not None: + if settings.absorber is not None: + msg = ( + "the settings already declare an absorber; give it there or as the " + "absorber keyword, not both" + ) + raise DriverError(msg) + changes["absorber"] = absorber + if scheme is not None: + if settings.absorber_scheme_named and settings.absorber_scheme != scheme: + msg = ( + f"the settings already declare absorber_scheme=" + f"{settings.absorber_scheme!r}; give it there or as the keyword" + ) + raise DriverError(msg) + changes["absorber_scheme"] = scheme + return dataclasses.replace(settings, **changes) if changes else settings + + +def _watched( + settings: SimulationSettings, check: BoundaryCheck | None +) -> SimulationSettings: + """Apply a spatial constructor's ``boundary_check`` keyword to its settings.""" + if check is DEFAULT_BOUNDARY_CHECK or check == settings.boundary_check: + return settings + if settings.boundary_check != DEFAULT_BOUNDARY_CHECK: + msg = ( + "the settings already declare a boundary_check; give it there or as the " + "boundary_check keyword, not both" + ) + raise DriverError(msg) + return dataclasses.replace(settings, boundary_check=check) + + +def _bounded( + settings: SimulationSettings, + spatial_absorber: SpatialAbsorber | Literal[False] | None, +) -> SimulationSettings: + """Apply a spatial constructor's ``spatial_absorber`` keyword to its settings. + + - ``spatial_absorber``: the keyword's value; ``None`` leaves the settings' own. + """ + if spatial_absorber is None or spatial_absorber == settings.spatial_absorber: + return settings + if settings.spatial_absorber is not None: + msg = ( + "the settings already declare a spatial_absorber; give it there or as " + "the spatial_absorber keyword, not both" + ) + raise DriverError(msg) + return dataclasses.replace(settings, spatial_absorber=spatial_absorber) + + +def _boundary( + absorber: Absorber | Literal[False] | None, + scheme: AbsorberScheme | None, + grid: TimeGrid, + *, + default: Callable[[TimeGrid], Absorber] | None = None, +) -> tuple[Absorber | None, AbsorberScheme | None]: + """Resolve a builder's absorber keywords (SEP 21 section 5). + + - ``absorber``: the collar given; ``False`` switches both soft collars off and keeps + the hard band mask; ``None`` takes `default`. + - ``scheme``: the temporal scheme given; ``None`` is the default scheme. + - ``default``: the route's collar when none is given; ``None`` is + :func:`default_collar`. + - Returns: the absorber (``None`` for none) and the scheme, ``None`` where the + settings are to take the route's default. + """ + if absorber is False: + if scheme is not None: + msg = ( + "absorber=False switches the temporal collar off, so there is nothing " + f"for absorber_scheme={scheme!r} to apply; give one or the other" + ) + raise DriverError( + msg, + parameter=("absorber", "absorber_scheme"), + rule="absorber-scheme-needs-absorber", + ) + return no_absorber(grid), None + if absorber is None: + absorber = default_collar(grid) if default is None else default(grid) + return absorber, scheme + + +def default_collar(grid: TimeGrid) -> Absorber: + """Return a route's default absorbing boundaries on a grid (SEP 21). + + :func:`solprop.boundaries.absorber` at its defaults, spectral-only when the grid + leaves too little room outside the request for a temporal collar to taper + (``collar_fraction=0`` and no rounding slack). + + Parameters + ---------- + grid : TimeGrid + The run's grid. + + Returns + ------- + Absorber + The default collar. + + Examples + -------- + >>> from solprop.driver.settings import default_collar + >>> from solprop.grid import FieldGrid + >>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15) + >>> default_collar(grid).temporal + True + """ + try: + return collar(grid) + except GridError: + # No room for a temporal taper outside the request: the spectral collar alone. + return collar(grid, temporal=False) + + +def _spatial_default( + settings: SimulationSettings, grid: TimeGrid +) -> SimulationSettings: + """Give a spatial run whose settings declare no absorber the default collar. + + The temporal and spectral collars become the routes' default (SEP 21, D6); the + transverse boundary is the settings' ``spatial_absorber`` (SEP 40). + """ + if settings.absorber is not None: + return settings + return dataclasses.replace(settings, absorber=default_collar(grid)) diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 242ad9ec..6b901007 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -25,128 +25,137 @@ is ``docs/guide/setup/running.md``. """ -import contextlib import copy import dataclasses import functools -import json import logging import math -import threading -import uuid import warnings -from collections.abc import Callable, Iterator, Mapping, Sequence -from dataclasses import dataclass +from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Final, Literal, Protocol, SupportsIndex, cast +from typing import TYPE_CHECKING, Literal, cast import numpy as np from jaxtyping import Complex, Float, Shaped -from scipy.special import j0, jn_zeros -import solprop.driver.tdse as tdse_runtime -from solprop._display import Displayed, engineering, line, summarise -from solprop.adiabatic import AdiabaticAccuracy -from solprop.backends.ledger import CallLedger +from solprop._display import Displayed, line, summarise from solprop.backends.protocol import Backend, _all_finite -from solprop.backends.registry import resolve_name, select -from solprop.backends.session import ExecutionOptions, Start, positive_count -from solprop.boundaries import ( - Absorber, - SpatialAbsorber, - no_absorber, - temporal_collar_rate, - warn_walkoff, -) -from solprop.boundaries import absorber as collar -from solprop.constants import C +from solprop.backends.registry import select +from solprop.boundaries import Absorber, SpatialAbsorber from solprop.coupling import ( CouplingLaunch, CouplingModel, - FieldPlane, InputPort, PlaneBasis, - PlaneGeometry, - PlaneReference, - basis_identity, - couple, modal_basis_identity, - space_record, ) -from solprop.driver import tdse_consistency -from solprop.driver._launch import launch_pulse, refuse_beside from solprop.driver.defaults import ( - CAPILLARY_RTOL, DEFAULT_ATOL, - DEFAULT_RTOL, - FREE_ATOL, - FREE_NOISE, - FREE_RTOL, INTERIOR_LAUNCH_NOISE, PLANAR_NOISE, - SLAB_NOISE, - SPATIAL_ATOL, - SPATIAL_RTOL, - TDSE_NOISE, WAVEGUIDE_ATOL, - WAVEGUIDE_RTOL, - NoiseModel, - default_controller, - default_integrator, - default_rtol, - default_scheme, resolve_noise, ) -from solprop.driver.material_output import ( - material_manifest, - material_record, - processing_record, +from solprop.driver.inputs import ( + ModalLaunch, + _bind_spatial_rates, + _built, + _built_state, + _check_initial_position, + _check_input_identity, + _check_planar_input, + _check_rate_inputs, + _given_input, + _redrawable, + _replaced_launch, + _shaped_pulse, + _shot_noise, + _spatial_draw, + _with_noise, +) +from solprop.driver.material_output import material_manifest +from solprop.driver.metadata import ( + RecipeSource, + _frame_follows_the_electronic_rule, + metadata_for, +) +from solprop.driver.mixtures import ResponseSelection, mixture_profiles +from solprop.driver.resume import ( + _adopt_recorded_seed, + _check_resume_choice, + _continues, + _interpolated, + _open_output, + _ResumeChoice, + _start, + _validate_resume, +) +from solprop.driver.routes.general import _from_general +from solprop.driver.routes.gnlse import _gnlse_fibre, _gnlse_grid +from solprop.driver.routes.hollow import ( + _flow_radius, + _from_hollow, + _population_label, + _rebind_mode, + _refuse_idle_preionisation, + _warn_boreless_gradient, + _warn_sampled_peak, ) -from solprop.driver.mixtures import ( - ResponseSelection, - capillary_fill, - is_air, - mixture_profiles, +from solprop.driver.routes.materials import ( + _check_electronic_policy, + _check_selection_carrier, + _refuse_dispersive_policy, ) -from solprop.driver.run import ( - RESTART_RECORD, - check_snapshot_request, - field_retention, - open_run_record, - retention_at, - run_problem, +from solprop.driver.routes.spatial import ( + _from_spatial, + _planar_medium, + _rechecked, + _refuse_pending_check, + _selected_medium, + _selected_planar, + _transverse_specification, +) +from solprop.driver.run import check_snapshot_request, field_retention, run_problem +from solprop.driver.runner import SimulationRunner, _peak_field_reports +from solprop.driver.settings import ( + _EXECUTION_SETTINGS, + _RECIPE_EXECUTION, + NoiseSetting, + SimulationSettings, + _boundary, + _bounded, + _keyword_controller, + _quiet_collar, + _same_setting, + _save_planes, + _scheme, + _spatial_default, + _spatial_settings, + _watched, + _with_boundary, +) +from solprop.driver.tdse_run import ( + _AtomicContext, + _check_tdse_archive, + _check_tdse_execution, + _close_run, + _consistency_gate, + _prepare_atomic, + _recorded_archive_uuid, + _resolve_tdse_grid, + _restore_atomic, ) from solprop.exceptions import ( AdiabaticTaperWarning, - BackendError, - BoundaryWarning, - CouplingError, DriverError, - GasFlowWarning, GrazingIncidenceWarning, - GridError, MaterialRangeWarning, - MaterialStatusWarning, - ParameterPath, - SampledPeakWarning, - SolpropError, SolpropWarning, StopRequested, - TDSEArchiveError, - TDSECostWarning, - TranslationError, ) -from solprop.fourier import power_amplitude from solprop.gasflow import VacuumEnd -from solprop.grid import ( - EnvelopeGrid, - FieldGrid, - FreeGrid, - LineGrid, - RadialGrid, - TimeGrid, -) -from solprop.linear import Frame, frame_origin, frame_quantities +from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid +from solprop.linear import Frame from solprop.log import ( DEFAULT_PROGRESS_PERIOD, ProgressChoice, @@ -163,4152 +172,2646 @@ taper_coupling_block, worst_wall_limits, ) -from solprop.modes.collection import ModeCollection, transverse_measure -from solprop.modes.graded_index import GradedIndexProvider -from solprop.modes.guide import ModalGuide, StepIndexProvider -from solprop.modes.material import JsonValue, MaterialRegion +from solprop.modes.guide import ModalGuide +from solprop.modes.material import JsonValue from solprop.modes.protocol import Mode, ParameterisedMode -from solprop.modes.quadrature import ( - RadialRule, - TransverseQuadrature, - cartesian_nodes, - mode_matrix, - on_axis_rule, - transverse_quadrature, -) -from solprop.modes.slab import PlanarGrid, SlabMode -from solprop.modes.stepindex import StepIndexMode +from solprop.modes.quadrature import RadialRule +from solprop.modes.slab import PlanarGrid from solprop.modes.tabulated import IndexTable from solprop.monitor import DEFAULT_BOUNDARY_CHECK, BoundaryCheck, BoundaryMonitor -from solprop.noise import ( - NoiseRealisation, - input_noise, - modified_noise, - spatial_noise, - transformed_noise, -) +from solprop.noise import NoiseRealisation from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse -from solprop.nonlinear.gnlse import ( - GnlseProblem, - SpecifiedMode, - attenuation_from_db, - problem_from, -) from solprop.nonlinear.ionisation import IntegrationPolicy, IonisationRate -from solprop.nonlinear.mixtures import electronic_response, responses_for -from solprop.nonlinear.optmat_raman import mixture_raman, raman_status -from solprop.nonlinear.optmat_rates import ( - OptmatRate, - PeakFieldReport, - bind_reference_field, - peak_field_report, - selected_rate, -) +from solprop.nonlinear.optmat_rates import PeakFieldReport from solprop.nonlinear.raman import ( DampedOscillator, MolecularOptions, RamanResponse, RotationalLines, - SilicaTensorResponse, -) -from solprop.nonlinear.responses import ( - DispersiveKerrResponse, - KerrResponse, - PlasmaResponse, ) -from solprop.nonlinear.tdse import TDSEAtomicWindow, TDSEExecution, TDSEGasResponse -from solprop.output.hdf5 import ( - HDF5Output, - ResumeState, - _stored_metadata, - check_cache, -) -from solprop.output.hdf5 import resume as resume_state -from solprop.output.memory import MemoryOutput +from solprop.nonlinear.responses import DispersiveKerrResponse, PlasmaResponse +from solprop.nonlinear.tdse import TDSEExecution, TDSEGasResponse from solprop.output.schema import ( INTERPOLATED_RESUMES_ANNOTATION, PEAK_FIELDS_ANNOTATION, CheckpointOutput, - DefaultsResolution, - FieldRetention, - FieldsChoice, - GridRecord, - MarchSnapshot, ModeRecord, - NoiseRecord, NoiseResolution, Output, PlaneStore, - RecipeRecord, RunMetadata, StatedInput, - StepLimits, - StepperRecord, - _recorded_inline, - field_retention_refusal, recorded_input, serialise, ) from solprop.output.snapshots import SnapshotController from solprop.output.tdse_archive import TDSEArchive, TDSEArchiveWriter -from solprop.physdata import ( - Gas, - Mixture, - _optmat, - as_gas, - fused_silica, - species, -) -from solprop.physdata.glasses import SilicaBundle +from solprop.physdata import Gas, Mixture, _optmat from solprop.physdata.mixtures import Pressure -from solprop.physdata.nonlinear_data import carriers_agree -from solprop.physdata.selection import ( - DISPERSIVE_POLICIES, - MaterialLog, - MaterialManifest, - MaterialSelection, -) +from solprop.physdata.selection import MaterialLog, MaterialManifest, MaterialSelection from solprop.physdata.sellmeier import GasSusceptibility -from solprop.physdata.species import Species, built_in_population, default_species -from solprop.profiles import ( - PressureGradient, - ZProfile, - as_profile, -) -from solprop.pulses import ( - GaussianPulse, - PlanarPulse, - Pulse, - PulseSum, - SechPulse, - SpatialPulse, - SpatialPulseSum, -) -from solprop.spatial import SpatialDispersion, SpatialFlux, SpatialMedium, peak_field -from solprop.spatial_absorber import SpatialAbsorberDesign -from solprop.stats import ( - PLASMA_INTEGRATION_STATISTICS, - StatsSpec, - analytic_field, - default_spec, -) +from solprop.profiles import ZProfile +from solprop.pulses import PlanarPulse, Pulse, SpatialPulse, SpatialPulseSum +from solprop.spatial import SpatialDispersion, SpatialMedium +from solprop.stats import StatsSpec, default_spec from solprop.stepper.controller import PIController from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.stepper.multistep import Multistep from solprop.stepper.runge_kutta import RungeKutta from solprop.stepper.schedule import FixedSchedule from solprop.stepper.tableau import DOPRI5 -from solprop.translate.coupled import ( - metric_at, - taper_identity, - wall_identity, -) -from solprop.translate.coupling import frame_record +from solprop.translate.coupled import taper_identity, wall_identity from solprop.translate.coupling import input_port as fill_port -from solprop.translate.envelope_plasma import ( - CycleIntegralData, - assembly_record, - cycle_moments, -) from solprop.translate.expansion import ParameterExpansion -from solprop.translate.flux import flux_record -from solprop.translate.general import ( - GeneralModalRoute, - general_modal_route, - modal_identity, -) -from solprop.translate.linop import power_weight_at -from solprop.translate.masks import ( - AbsorberScheme, - noise_spectrum, -) -from solprop.translate.plasma_integration import ( - integration_record, -) -from solprop.translate.plasma_source import _BENCHMARK_GATED, plasma_source_record -from solprop.translate.problem import ( - DEFAULT_COMPONENTS, - PropagationProblem, - PropagatorMode, - StepperSettings, - TranslationReuse, - _held_block, - _held_state, - _hold, - _state, - assemble, -) +from solprop.translate.general import GeneralModalRoute, modal_identity +from solprop.translate.masks import AbsorberScheme +from solprop.translate.problem import PropagationProblem, TranslationReuse, assemble from solprop.translate.responses import KerrForm, declares_harmonic_carrier -from solprop.translate.routes import ( - SpatialRoute, - flux_of, - tdse_descriptor, - tdse_of, - transverse_of, -) +from solprop.translate.routes import SpatialRoute, tdse_descriptor, transverse_of from solprop.translate.run_request import RunRequest -from solprop.translate.tdse import AtomicCallLedger, TDSEBinding, refine_response_grid -from solprop.translate.tdse_source import TDSE_UNAVAILABLE_STATISTICS -from solprop.translate.transverse import ( - band_wavenumbers, - checked_design, - resolve_transverse, -) +from solprop.translate.tdse import TDSEBinding +from solprop.translate.transverse import band_wavenumbers from solprop.transverse_monitor import TransverseEdge -__all__ = [ - "BACKEND_STATISTICS", - "CAPILLARY_RTOL", - "DEFAULT_ATOL", - "DEFAULT_N_SAVE", - "DEFAULT_RTOL", - "FREE_ATOL", - "FREE_NOISE", - "FREE_RTOL", - "INTERIOR_LAUNCH_NOISE", - "LOOSE_ADAMS_RTOL", - "LOW_STORAGE_BACKENDS", - "ORTHONORMALITY_TOLERANCE", - "PLANAR_NOISE", - "PLASMA_STATISTICS", - "SLAB_NOISE", - "SPATIAL_ATOL", - "SPATIAL_RTOL", - "TDSE_NOISE", - "WAVEGUIDE_ATOL", - "WAVEGUIDE_RTOL", - "ModalLaunch", - "NoiseModel", - "NoiseSetting", - "RecipeSource", - "Simulation", - "SimulationRunner", - "SimulationSettings", - "check_derived_problem", - "default_collar", - "default_field_range", - "default_radial_order", - "metadata_for", -] - -logger = logging.getLogger(__name__) - -type _ResumeChoice = bool | Literal["interpolated"] -"""What ``resume`` takes: no, yes, or from a plane interpolated inside a step.""" - - -LOOSE_ADAMS_RTOL = 5e-2 -"""The ``rtol`` from which an adaptive Adams run is warned on. - -An error of five per cent or more allowed per step compounds through the method's -history, and its predictor-corrector estimate is unreliable there -(``docs/guide/numerics/choosing-a-stepper.md``). -""" - -DEFAULT_N_SAVE = 201 -"""Planes a GNLSE run stores, counting the one at ``z = 0``.""" - - -PLASMA_STATISTICS = ("electron_density", "peak_ionisation_rate") -"""Backend statistics that read the plasma the run compiled; refused without one.""" - -BACKEND_STATISTICS = ("peak_intensity", "quadrature_error", *PLASMA_STATISTICS) -"""The statistics only a backend can compute, recorded by a capillary run by default. - -Each needs something the kernel contract does not carry to a later pass: the quadrature -nodes the plasma was evaluated at, the mode set the axis needs, or the embedded -coarse-rule projection, which is gone once the step is accepted. The two plasma entries -are recorded only by a run that has a plasma response; the backend refuses them -otherwise, and a default that always asked for them would make ``plasma=False`` fail. -""" - -ORTHONORMALITY_TOLERANCE = 1e-6 -"""How far a given mode set may stray from orthonormality before it is refused. - -The transform is one synthesis matrix and its dual, which represent an orthonormal -basis and nothing else, so a set that is not one is propagating a different problem -from the one it describes. The bound is loose because the overlaps are integrated -adaptively by :func:`scipy.integrate.dblquad`, whose own accuracy on a set of Bessel -profiles is around 1e-9. -""" - -_MINIMUM_RADIAL_ORDER = 32 -"""Floor on the default radial order; see :func:`default_radial_order`.""" - -_RADIAL_NODES_PER_MODE = 8 -"""Radial nodes per mode in the default order; see :func:`default_radial_order`.""" +if TYPE_CHECKING: + from solprop.calibration.refine import RefinementReport + from solprop.driver.estimate import RunEstimate -_FIELD_RANGE_BELOW = 1e-3 -"""Lower end of the default ionisation table, as a factor on the input's peak field.""" +__all__ = ["Simulation"] -_FIELD_RANGE_ABOVE = 30.0 -"""Upper end of the default ionisation table, as a factor on the input's peak field.""" +logger = logging.getLogger(__name__) _PRESSURE_FORM_ENTRIES = 2 """Entries in either of the two pair forms of the ``pressure`` keyword.""" -_RECIPE_EXECUTION = ("fields", "field_planes", "resume_history") -"""The settings a recipe's ``[execution]`` table holds: a change of any other one drops -the recipe, which would no longer describe the run (SEP 41, S2).""" - - -def _held_budget(value: SupportsIndex | None) -> int | None: - """Return a settings node budget as an ``int``, refused before translation. - - Any integral budget, a NumPy integer among them, is held as an ``int``, and a - budget that is not one is refused when the settings are made, rather than when the - backend is built after the output has opened. - - - ``value``: the budget given, bytes. - - Returns: the budget, or ``None`` for the backend's automatic choice. - """ - if value is None: - return None - try: - return positive_count("node_budget", value) - except BackendError as error: - raise DriverError(str(error), parameter="node_budget") from None - - -_EXECUTION_SETTINGS = ( - "backend", - "node_budget", - "resume_history", - "tdse_execution", - "fields", - "field_planes", - "boundary_check", -) -"""The settings read when a run executes, not when it is translated.""" - -NoiseSetting = Literal["default", "input", "modified"] | None -"""What a constructor's ``noise`` keyword takes (SEP 37). - -A model, ``None`` for a noiseless run, or ``"default"``, the keyword's default, which -resolves to ``"input"`` wherever the run can carry noise and to none where it cannot, -recording which in the run's :class:`~solprop.output.schema.NoiseResolution`. -""" - -ModalLaunch = ( - Mapping[str | int, float | Mapping[str, float]] - | Sequence[Mapping[str | int, float | Mapping[str, float]]] -) -"""What a modal constructor's ``launch`` keyword takes (SEP 41, S5). - -A mapping from a mode's display label or index to its share, or to -``{"share": s, "phase": phi}`` with the phase in radians; or a list of such mappings, -one for each input pulse (``docs/guide/setup/pulses.md``). -""" - - -class _Defaulted(str): - """A setting's value that a default rule chose rather than the caller. - A copy made with :func:`dataclasses.replace` carries the marker with the value, so - the copy resolves the rule again for its own route or planes (the collar scheme), - while a value the caller names, even an equal one, is a plain string and stays as - given. - """ - - __slots__ = () - - -if TYPE_CHECKING: - from solprop.calibration.refine import RefinementReport - from solprop.driver.estimate import RunEstimate - - -class RecipeSource(Protocol): - """What built a simulation from a recipe: it writes the record its output keeps. +def _one_material_context[**P, R](build: Callable[P, R]) -> Callable[P, R]: + """Run a constructor inside one material evaluation context, the run's own. - :class:`solprop.recipe.core.Recipe` is one (SEP 41, S2); the driver needs only this. + The simulation it builds adopts the context's log + (:class:`solprop.physdata.selection.MaterialLog`), and its translation, record and + run resume it, so that every range, status and dilute-scaling warning of its + materials is raised once per run, however its work is spread in time. """ - def record(self, simulation: "Simulation") -> RecipeRecord: - """Return the authored and resolved recipe of a run about to start. - - Parameters - ---------- - simulation : Simulation - The simulation, translated, with its settings at run start. + @functools.wraps(build) + def inside(*args: P.args, **kwargs: P.kwargs) -> R: + """Build the simulation with the run's material context open.""" + with _optmat.material_context(): + return build(*args, **kwargs) - Returns - ------- - solprop.output.schema.RecipeRecord - The record its output keeps. - """ - ... + return inside -@dataclass(frozen=True, eq=False) -class SimulationSettings(Displayed): - """How a run is stepped, what it records and which backend executes it. +class Simulation(Displayed): + """A run that can be translated once and executed many times. - Everything that is *not* a physics object, in one group, so that a simulation can be - constructed from the physics and this. Equality is by identity (``eq=False``) - because ``save_planes`` is an array. + Construct it from the physics objects and a + :class:`~solprop.driver.settings.SimulationSettings`, or through + :meth:`from_gnlse` for a GNLSE fibre. :meth:`translate` assembles the kernel problem + and caches it, so a second :meth:`run` -- into another handler, or resuming a + file -- reuses it. :attr:`settings` may be replaced until that first translation; + after it only its ``backend`` may change. - Attributes + Parameters ---------- - controller : solprop.stepper.controller.PIController or None - Tolerances, safety and gains; the tolerances and the norm choice live here and - nowhere else (``docs/dev/kernel-contract.md`` section 7). ``None``, the default, - takes the route's from :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` (SEP 37) - when an interface builds the run: the - :data:`~solprop.driver.defaults.SPATIAL_RTOL` and - :data:`~solprop.driver.defaults.SPATIAL_ATOL` of :meth:`Simulation.from_radial` - and :meth:`Simulation.from_planar`, the - :data:`~solprop.driver.defaults.FREE_RTOL` and - :data:`~solprop.driver.defaults.FREE_ATOL` of :meth:`Simulation.from_free`. - An explicit controller always wins, and a default copied into another route - takes that route's default. The other fields are keyword-only. - save_planes : numpy.ndarray - Positions to save at, m, increasing and positive, shape ``(n_z,)``. The plane at - ``z = 0`` is saved as well and is not listed here. A sequence is adopted as a - read-only float64 array, so that two runs specified the same way record the same - planes whichever type they passed and therefore hash alike. - dz_max : float - Cap on the controller's proposal, m. - dz_init : float - First step, m. - dz_min : float or None - Floor on the proposal, m; ``None`` takes - :func:`solprop.stepper.controller.default_dz_min` of the run length. A proposal - below it raises rather than being clamped silently. - stretch : float or None - Landing stretch factor; ``None`` takes - :data:`solprop.translate.problem.DEFAULT_STRETCH`. - auto_init : bool - Whether the start-up rule takes the step after the first from the first step's - own error estimate, rather than growing from `dz_init` at the growth cap (SEP - 14). The convenience constructors set it when no `dz_init` is given. It is off - for a coupled flow and a reject-free controller. - interval_cap : bool or None - Whether a step is capped at the width of its operator-table interval. ``None`` - caps it only for a coupled flow or a reject-free controller (SEP 14). - interpolate_planes : bool - Observe the save planes inside accepted steps instead of landing on them (SEP - 14 phase 3): through the Adams march's own polynomial or, with - ``RungeKutta()``, DOPRI5's continuous extension. Every plane but the run's end - and a hard kink is then interpolated. Refused beside a coupled flow, the - per-step temporal collar and the ``modified`` noise. - step_planes : int - A positive ``k`` also saves the end of every ``k``-th accepted step, the - march's own positions, beside the requested planes (SEP 14 phase 3); ``0`` - saves the requested planes only. Not in scans. - integrator : Multistep, RungeKutta, LowStorageRungeKutta or None - The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``Multistep()``, or ``RungeKutta()`` where the - interface's builder sets it (:meth:`Simulation.from_gnlse`, - :meth:`Simulation.from_waveguide` and :meth:`Simulation.from_free`; - ``docs/guide/numerics/choosing-a-stepper.md``); the stored value is the - resolved method. ``Multistep()`` is the low-call exponential Adams - method, which - ``numpy_ref`` and ``native`` execute for supported - configurations; - ``RungeKutta()`` is the interaction-picture DOPRI5 march, which every backend - executes and which holds a third of the Adams history's memory. - ``LowStorageRungeKutta()`` replays `schedule` with RK4 in three registers and - no error estimate (SEP 24); every backend executes it - (:data:`LOW_STORAGE_BACKENDS`), and ``"auto"`` resolves as for any integrator. - schedule : solprop.stepper.schedule.FixedSchedule or None - A fixed schedule to replay instead of stepping adaptively - (``docs/dev/stepper.md`` section 8). Its edges must contain every save plane, - which :meth:`solprop.stepper.schedule.FixedSchedule.uniform` guarantees. - propagator_mode : {'auto', 'tabulate', 'recompute'} - Whether the backend tabulates the stage propagators - (``docs/dev/kernel-contract.md``). - density : solprop.profiles.ZProfile or float or None - Number density along z in m^-3 that multiplies the compiled responses; ``None`` - takes the mode's own path. The GNLSE passes 1.0 - (``docs/guide/waveguides/gnlse.md``). - Refused beside a mixture and beside a gas medium, which carry their own. - shock : bool - Keep the ``omega`` dependence of the normalisation prefactor, which is the - optical shock term. - prefactor_index : float or None - Use this constant in the prefactor and power weight; None uses Re n_eff. - The GNLSE passes 1.0, leaving its power normalisation and Phi unchanged. - absorber_scheme : {'nonlinear', 'split_step'} or None - How the temporal collar is applied (``docs/guide/numerics/boundaries.md``). - ``None``, the default, is the route's: ``"split_step"`` on the radial, - free-space and planar routes unless planes are interpolated (SEP 21), and - ``"nonlinear"`` elsewhere. ``"nonlinear"`` adds ``-alpha(t) E(t) / 2`` inside - every stage; ``"split_step"`` multiplies the accepted state by - ``exp(-alpha(t) h / 2)`` once per step, a Lie splitting, first order wherever - the collar does any work and unconditionally stable. - absorber : solprop.boundaries.Absorber or None - The collar rates. Its spectral part folds into the linear operator and its - temporal part crosses the seam as a rate - (``docs/guide/numerics/boundaries.md``). - quadrature : solprop.modes.quadrature.TransverseQuadrature or None - The fixed transverse rule a multimode run integrates the nonlinear polarisation - on (``docs/guide/waveguides/mode-sets.md``). ``None`` builds the mode-averaged - single-node transform, which needs exactly one mode. - components : sequence of str or None - Which transverse Cartesian components the multimode transform resolves at each - node, a non-empty subset of ``("x", "y")``; ``None`` takes the union of - the modes' nonzero components at the entrance (``("x",)`` for HE_1m modes - at ``phi = 0``; ``docs/dev/conventions.md`` section 5). Refused without a - `quadrature`. - ionisation_field_range : tuple of float or None - Lowest and highest field amplitude in V/m the compiled ionisation-rate table - has to cover. Required beside a plasma response whose rate is not already a - table; the table holds its last value above the upper limit, so one set too - low is a silent clip. - node_budget : int or None - Bytes of node-chunk buffers a response pass may hold - (``docs/dev/architecture.md`` section 13). An execution setting, outside the - resume key: the backend plans its node chunk from it when it is prepared, and - it may change after translation. ``None``, the default, leaves the choice to - the backend, which takes 1 GiB on a free-space, line or planar grid and 64 MiB - otherwise (:data:`~solprop.backends.node_chunk.DEFAULT_SPATIAL_NODE_BUDGET`, - :data:`~solprop.backends.node_chunk.DEFAULT_NODE_BUDGET`); an explicit budget - is authoritative. - stats : solprop.stats.StatsSpec or None - Which statistics to collect at every saved plane; ``None`` collects none. - polarisation_taper : bool - Whether the spectral collar tapers the nonlinear polarisation as well as - absorbing the field through the linear operator. Default ``False``, which is - :func:`solprop.translate.problem.assemble`'s: the taper costs a systematic - error of the size of the field's content in the collar, and the rate absorber - acts either way (``docs/guide/numerics/boundaries.md``). - backend : str - Registry name of the backend to execute the run. The default ``"auto"`` - resolves to ``"native"`` at settings construction, and refuses if it isn't - installed or this processor is below its baseline. - The stored name is concrete, including in scan workers. A backend's import or - construction errors never trigger a fallback. - resume_history : bool or None - Whether the output keeps the Adams march's state at every save plane and - terminal checkpoint, so that a resume continues the history -- its samples, its - order and its proposed step -- as the uninterrupted run would, instead of - starting it afresh at order one. ``None``, the default, keeps it while it is at - most :data:`solprop.driver.run.MARCH_SNAPSHOT_AUTO_BYTES`; it is outside the - resume key. Every backend's fused and exponential Adams engines, the native - backend's resident one included, export and install their history. - fields : {'all', 'planes', 'none'} - Which saved planes keep their field (SEP 22): every one, the default; those - of `field_planes`; or none, for a run wanted as statistics alone. A plane - without its field stores no field and no noise phase, and an in-memory output - is allocated for the kept fields only; the march is the same. A - terminal checkpoint carries the field anyway, so a stopped run resumes, but a - file whose last regular plane kept no field resumes only from one. Outside - the resume key, like `resume_history`: it changes what is kept, not what is - computed. In a scan, :func:`~solprop.driver.scan.run_scan`'s own `fields` - decide, and a builder that sets this is refused. Beside - ``LowStorageRungeKutta()``, a step abandoned in flight leaves no checkpoint, so - a file resumes from the last plane that kept its field. - field_planes : sequence of int - Saved-plane indices for ``fields='planes'``, the entrance as 0 and negative - from the end; empty otherwise. Refused beside `step_planes`, whose plane count - is not known in advance. - evanescent_rate : float or None - The spatial operator's evanescent field attenuation cap in m^-1, positive and - finite (SEP 22 section 4.4). ``None``, the default, keeps - :data:`solprop.spatial.EVANESCENT_RATE`; - :func:`solprop.spatial.physical_evanescent_rate` gives the rate at which the - grid's non-propagating content decays physically. Refused without a spatial - grid; a replayed step it would overflow is refused at translation. - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None - The transverse boundary of a free-space run (SEP 40). ``None``, the default, - is ``SpatialAbsorber(real_space=False)``: a wall or a wrap at the grid's - outer size and a safety ramp on its outer wavenumbers, folded into the - operator as an exact diagonal rate. A - :class:`~solprop.boundaries.SpatialAbsorber` asks for a real-space collar - too, in the room the grid has outside its requested size - (``collar_fraction``). ``False`` carries no ramp either; the boundary - monitor still runs. A specification is refused on modal runs. - adiabatic_accuracy : solprop.adiabatic.AdiabaticAccuracy or None - The forward-field and reflected-energy budgets a radial medium varying - along z must meet (SEP 34 section 4.5). ``None``, the default, is - ``AdiabaticAccuracy()``, both 1e-4; a path exceeding them is refused. - tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None - Explicit atomic runtime resources for a TDSE response. Requires an explicit - ``backend="numpy_ref"`` and a Multistep integrator. Execution resources are - outside the scientific resume identity; attempted-call budgets are cumulative. - boundary_check : solprop.monitor.BoundaryCheck or None - How the run watches its time window's edges (SEP 21): guard and collar - occupancy at the launch and every committed step, a warning and then a stop - when light reaches the edge, and the energy the temporal collar removed at the - end. The default :class:`~solprop.monitor.BoundaryCheck` warns at a field-error - budget of 1e-3 and stops at ten times it; ``None`` watches nothing. + grid : solprop.grid.TimeGrid + The field or envelope grid. + modes : solprop.modes.protocol.Mode or sequence of Mode + The mode, or the ordered set of a multimode problem. + responses : collections.abc.Sequence + The declared nonlinear responses, as + :func:`solprop.translate.responses.compile_responses` takes them. + initial : solprop.pulses.Pulse or solprop.pulses.SpatialPulse or numpy.ndarray + The input: a :class:`solprop.pulses.Pulse` -- anything with a ``build(grid)`` + method -- or an already-built spectrum of shape ``(n_modes, n_pol, n_omega)`` in + the square root of watts and the internal convention. A pulse is the better + choice: it is a small specification, while a built array is recorded in the + metadata as itself. An array is copied here, so that changing the caller's + array afterwards changes neither the run nor its record: once in complex128, + which runs, and, for another dtype, once more as given, which is recorded; + ``Simulation.initial`` is a read-only view of the copy as given. A broadcast + view (:func:`numpy.broadcast_to`) copies only the block it repeats. On a + free-space or spatial route the translated problem holds the same complex128 + copy rather than another; on a modal route it holds the launch converted to + its internal amplitude beside the simulation's copy, two states in all. + settings : SimulationSettings + How to step it, what to record and which backend to use. + initial_z : float, optional + Absolute position of an array input, m, default zero. Every requested save + plane must follow it. An interior launch keeps the original profiles and + propagation frame; it requires a modal array input, no noise and no fixed + step schedule. + noise : solprop.noise.NoiseRealisation or None, optional + A shot-noise realisation to carry along the run (``docs/guide/media/noise.md``). + noise_resolution : solprop.output.schema.NoiseResolution or None, optional + How the noise model was chosen, as the constructors record it (SEP 37); ``None`` + records the realisation as asked for explicitly. + profiles : collections.abc.Mapping, optional + The z-profiles keyed by statistic name, for the ``density``, ``pressure`` and + ``core_radius`` statistics. Passed to the backend, which computes them. + configuration : collections.abc.Mapping, optional + Extra provenance to record beside what the simulation can see for itself, such + as the coefficients a mapping came from. + spatial_grid : RadialGrid, FreeGrid, PlanarGrid or None, optional + Transverse grid for spatial propagation; use an empty mode sequence (a + :class:`solprop.modes.slab.PlanarGrid` carries its own slab modes). + medium : solprop.spatial.SpatialMedium or None, optional + Bulk material, required with a spatial grid. + frame : solprop.linear.Frame or None, optional + Propagation frame. None uses the grid and responses' normal default. + general : solprop.translate.general.GeneralModalRoute or None, optional + The seam leaves of one general modal guide. Built by :meth:`from_waveguide` from + a ``provider=``; with it the run takes the general modal route, whose responses + are the guide's material regions' and whose declared `responses` must therefore + be empty. + guide : solprop.modes.guide.ModalGuide or None, optional + The general modal guide `general` was built from, kept so that the run's + material manifest finds what its regions hold (their laws and delayed + responses); it enters no translation. Set by :meth:`from_waveguide`. + wall : solprop.modes.capillary.CoherentWall or None, optional + The declaration of a coherent wall block. Built by :meth:`from_capillary` + from a ``wall=``; with it the mode set must be the contiguous ``HE_11`` to + ``HE_1N`` block -- or, with ``impedance="te_tm"``, one azimuthal order -- on a + constant or a varying guide, the cumulative operator carries the + mode-independent carrier alone and the modal part of the flow becomes + ``linear.coupled_flow``, which ``numpy_ref`` and ``native`` execute. + taper_coupling : bool, optional + Carry the **taper coupling** of a multimode capillary whose radius varies along + z: the connection ```` between the ``HE_1m`` channels, lossless + or not, in a coupled flow beside each channel's own propagation constant. + ``True``, the default; ``False`` is the explicit opt-out to the **adiabatic** + route, on which each channel follows its local mode and no power is exchanged. + No effect on a set the coupling does not apply to + (:func:`solprop.modes.capillary.taper_coupling_block`); ``False`` beside a + `wall` is refused, because the wall carries its connection. + materials : solprop.physdata.selection.MaterialSelection or None, optional + The material selection the run was built with, recorded in its material + manifest with every optmat-backed object the run carries. + The objects themselves are taken as they are: the ``from_*`` constructors + resolve the built-in materials under the selection. ``None`` records none. Raises ------ DriverError - If the save planes are empty, not increasing, or not positive. The same check - runs again in :func:`solprop.translate.problem.assemble`; here it fires before a - run has built anything. Also if the field retention is not valid, asks for - ``fields='planes'`` beside `step_planes`. + If `initial` is neither an array nor something with a ``build`` method. Examples -------- - >>> from solprop.driver import SimulationSettings + >>> from solprop.driver import Simulation, SimulationSettings + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.nonlinear.gnlse import problem_from + >>> from solprop.pulses import SechPulse >>> from solprop.stepper import PIController - >>> settings = SimulationSettings( - ... controller=PIController(rtol=1e-8, atol=1e-14), - ... save_planes=[0.5, 1.0], dz_max=0.1, dz_init=1e-3, - ... backend="numpy_ref", + >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1.2e-12) + >>> gnlse = problem_from([0.0, 0.0, -1.183e-26], omega_ref=grid.omega0, gamma=0.11, + ... shock=False) + >>> simulation = Simulation( + ... grid, gnlse.mode, [gnlse.kerr], + ... SechPulse(wavelength=835e-9, width=100e-15, peak_power=9.8), + ... settings=SimulationSettings( + ... controller=PIController(rtol=1e-6, atol=1e-16), + ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3, + ... density=1.0, shock=False, prefactor_index=1.0, + ... ), ... ) - >>> settings.save_planes.tolist(), settings.backend - ([0.5, 1.0], 'numpy_ref') + >>> handler = simulation.run() + >>> handler.n_saved, simulation.translate().shape + (2, (1, 1, 1024)) """ - controller: PIController | None = None - _: dataclasses.KW_ONLY - save_planes: Float[np.ndarray, " n_z"] | Sequence[float] - dz_max: float - dz_init: float - dz_min: float | None = None - stretch: float | None = None - auto_init: bool = False - interval_cap: bool | None = None - interpolate_planes: bool = False - step_planes: int = 0 - schedule: FixedSchedule | None = None - propagator_mode: PropagatorMode = "auto" - density: ZProfile | float | None = None - shock: bool = True - prefactor_index: float | None = None - absorber: Absorber | None = None - absorber_scheme: AbsorberScheme | None = None - quadrature: TransverseQuadrature | None = None - components: Sequence[str] | None = None - ionisation_field_range: tuple[float, float] | None = None - node_budget: int | SupportsIndex | None = None - stats: StatsSpec | None = None - polarisation_taper: bool = False - backend: str = "auto" - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None - resume_history: bool | None = None - tdse_execution: TDSEExecution | None = None - fields: FieldsChoice = "all" - field_planes: Sequence[int] = () - evanescent_rate: float | None = None - spatial_absorber: SpatialAbsorber | Literal[False] | None = None - adiabatic_accuracy: AdiabaticAccuracy | None = None - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK - _default_integrator: Multistep | RungeKutta | None = dataclasses.field( - default=None, repr=False, compare=False - ) - """The default the settings chose for an unnamed integrator. A copy made with - :func:`dataclasses.replace` carries it with the integrator, so the copy still knows - the caller named none; any other integrator counts as named.""" - _route: str | None = dataclasses.field(default=None, repr=False, compare=False) - """The row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the interface that - built the settings reads, or ``None`` for settings built by hand. A copy made with - :func:`dataclasses.replace` carries it, so a copy takes the route's defaults - again, and the route's choice never counts as named.""" - _default_controller: PIController | None = dataclasses.field( - default=None, repr=False, compare=False - ) - """The controller the settings took from the route's defaults. A copy carries it - with the controller, so the copy still knows the caller gave none; any other - controller counts as given.""" - # Whether the caller named the integrator, set per instance in __post_init__. - integrator_named = False - # Whether the controller is the route's default, set per instance in __post_init__. - controller_defaulted = False - - def _planes_text(self) -> str: - """Return the save planes summarised, for printing.""" - planes = np.asarray(self.save_planes, dtype=float) - return summarise(planes, "m").replace("values", "planes") - - def __repr__(self) -> str: - """Return a one-line description with the save planes summarised, not dumped. + # How a constructor that drew the noise without a seed draws it again from a given + # one: set per instance by `_redrawable` and carried to a run derived with its + # noise kept (`_rebound_noise`), read by `_reseed`. + _noise_redraw: Callable[[int | None], NoiseRealisation | None] | None = None + # The recipe the simulation was built from (SEP 41, S2), set by + # solprop.recipe.Recipe.build and dropped by every change it would not describe. + _recipe: RecipeSource | None = None - Returns - ------- - str - For example ``SimulationSettings(<201 planes, 0 m to 1.5 m>, rtol=1e-06, - backend=auto)``. - """ - return line( - "SimulationSettings", - [ - ("", self._planes_text()), - *( - [("", "interface default tolerances")] - if self.controller is None - else [ - ("rtol", f"{self.controller.rtol:g}"), - ("atol", f"{self.controller.atol:g}"), - ] - ), - ("backend", self.backend), - ], + def __init__( + self, + grid: TimeGrid, + modes: Mode | Sequence[Mode], + responses: Sequence[object], + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + *, + settings: SimulationSettings, + initial_z: float = 0.0, + noise: NoiseRealisation | None = None, + noise_resolution: NoiseResolution | None = None, + profiles: Mapping[str, ZProfile] | None = None, + configuration: Mapping[str, object] | None = None, + spatial_grid: RadialGrid | FreeGrid | PlanarGrid | None = None, + medium: SpatialMedium | None = None, + frame: Frame | None = None, + general: GeneralModalRoute | None = None, + guide: ModalGuide | None = None, + wall: CoherentWall | None = None, + taper_coupling: bool = True, + materials: MaterialSelection | None = None, + ) -> None: + """Adopt the physics objects and the settings; translate nothing yet.""" + _check_selection_carrier(materials, grid.reference_wavelength) + self._base_grid = grid + declared = tuple(responses) + _check_electronic_policy(materials, declared) + grid, settings = _resolve_tdse_grid(grid, declared, settings) + self.grid = grid + self.materials = materials + # The run's material log: the one its constructor opened, or a new one, which + # its translation, record and run resume (`_one_material_context`). + self._material_log = _optmat.open_log() or MaterialLog() + self.general = general + self.guide = guide + self.wall = wall + self.taper_coupling = taper_coupling + self.modes: tuple[Mode, ...] = ( + tuple(modes) if isinstance(modes, Sequence) else (modes,) ) - - def _default_text(self, text: str) -> str: - """Mark the integrator's name when it is the route's default. - - - Returns: `text`, then ``"(default)"`` or ``"(gnlse default)"`` where no - integrator was named. - """ - if self.integrator_named: - return text - return f"{text} ({'' if self._route is None else self._route + ' '}default)" - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - schedule = "adaptive" if self.schedule is None else type(self.schedule).__name__ - return "SimulationSettings", [ - ("save planes", self._planes_text()), - ("integrator", self._default_text(type(self.integrator).__name__)), - ( - "tolerances", - "the interface's default" - if self.controller is None - else f"rtol {self.controller.rtol:g}, atol {self.controller.atol:g}" - + ( - f" ({self._route} default)" - if self.controller_defaulted and self._route is not None - else "" - ), - ), - ("steps", f"{schedule}, dz_max {engineering(self.dz_max, 'm')}"), - ("shock term", self.shock), - ( - "absorber", - ( - f"{_scheme(self)} scheme" - + ("" if self.absorber_scheme_named else " (default)") - ) - if self.absorber is not None - else "none", - ), - ("statistics", "none" if self.stats is None else type(self.stats).__name__), - ("backend", self.backend), - ] - - def __post_init__(self) -> None: - """Adopt a read-only plane array and check it. - - Raises - ------ - DriverError - If the planes are empty, not increasing, or not positive. - """ - planes = np.ascontiguousarray(self.save_planes, dtype=np.float64).reshape(-1) - if ( - planes.size == 0 - or not np.all(np.isfinite(planes)) - or not np.all(np.diff(planes) > 0.0) - or planes[0] <= 0.0 - ): - msg = ( - f"save_planes must be positive and increasing, got {planes}; the plane " - "at z = 0 is saved anyway and is not one of them" - ) - raise DriverError(msg, parameter="save_planes") - planes.setflags(write=False) - object.__setattr__(self, "save_planes", planes) - refusal = field_retention_refusal(self.fields, self.field_planes) - if refusal is not None: - raise DriverError(refusal, parameter=("fields", "field_planes")) - object.__setattr__(self, "field_planes", tuple(self.field_planes)) - object.__setattr__(self, "node_budget", _held_budget(self.node_budget)) - self._resolve_defaults() - if self.fields == "planes" and self.step_planes: + self.responses = declared + self.initial = initial + self.initial_z = initial_z + self._problem: PropagationProblem | None = None + # The request translation returned with the problem: every policy of a fresh + # run of it (SEP 47 section 5.2). + self._request: RunRequest | None = None + # The transverse edge's smallest band wavenumber by run length, fixed with the + # medium and grids: a planar run's costs a slab-mode evaluation per mode, which + # every run repeated (``_transverse_edge``). + self._edge_k_min: dict[float, float] = {} + self.settings = settings + if settings.controller is None: msg = ( - "fields='planes' names saved planes by index, and step_planes saves " - "the march's own steps as well, so the plane count is not known in " - "advance; keep fields='all' or 'none' beside step_planes" - ) - raise DriverError( - msg, - parameter=("fields", "step_planes"), - rule="fields-planes-step-planes", + "settings.controller is None: Simulation.from_radial and " + "Simulation.from_planar supply their interface's default tolerance, " + "but a Simulation built directly needs a PIController" ) - if self.tdse_execution is not None: - _admit_tdse_execution(self) - # The annotation refuses another integrator first wherever the annotations are - # checked at run time, as in the test suite. - if not isinstance( - self.integrator, Multistep | RungeKutta | LowStorageRungeKutta - ): # pragma: no cover - msg = ( - "integrator must be Multistep(), RungeKutta() or " - f"LowStorageRungeKutta(), got {self.integrator!r}" + raise DriverError(msg) + self.noise = noise + self.noise_resolution = ( + NoiseResolution( + None if noise is None else noise.model, + None if noise is None else noise.model, ) - raise DriverError(msg, parameter="integrator") - if isinstance(self.integrator, LowStorageRungeKutta): - backend = _admit_low_storage(self.integrator, self.backend, self) - object.__setattr__(self, "backend", backend) - # An Adams integrator resolves "auto" as the Runge-Kutta march does: every - # backend steps the exponential formulation on every route, and the - # interaction one on every route but a coupled flow, which it refuses by name - # (SEP 16 phases 3 and 5). - object.__setattr__(self, "backend", resolve_name(self.backend)) - if isinstance(self.integrator, Multistep): - _admit_integrator(self.integrator, self.backend) - rtol = getattr(self.controller, "rtol", None) - if self.schedule is None and rtol is not None and rtol >= LOOSE_ADAMS_RTOL: - msg = ( - f"rtol={rtol:g} is loose for the Adams method: an error of this " - "size allowed per step compounds through its history, and its " - "error estimate is unreliable there. Take rtol at most 1e-2, or " - "integrator=RungeKutta() for a rough look" - ) - warnings.warn(msg, SolpropWarning, stacklevel=3) + if noise_resolution is None + else noise_resolution + ) + self.profiles = dict(profiles or {}) + self.configuration = dict(configuration or {}) + if (spatial_grid is None) != (medium is None): + msg = "spatial_grid and medium must be supplied together" + raise DriverError(msg) + if spatial_grid is not None and self.modes: + msg = "spatial propagation requires an empty mode sequence" + raise DriverError(msg) + self.spatial_grid = spatial_grid + self.medium = medium + self.frame = frame + _check_initial_position(self, settings) + _check_tdse_execution(self, settings) + self._metadata: RunMetadata | None = None + self._mixture_selection: ResponseSelection | None = None + self._peak_fields: tuple[PeakFieldReport, ...] = () + self._reuse: TranslationReuse | None = None + self._input_identity: str | None = None + self._record_keys: set[str] | None = None + self._field0 = self._built_input(initial, grid, initial_z) + if isinstance(initial, np.ndarray): + # What was passed is what runs and what is recorded: a read-only view of + # the simulation's own copy, as the caller gave it (`_given_input`). + self.initial = _given_input(initial, self._field0) + + def _built_input( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + grid: TimeGrid, + initial_z: float, + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return the input as the simulation holds it, building a pulse on its grids. - def _resolve_defaults(self) -> None: - """Take the route's default for each of the stepping choices the caller left. + A spatial pulse is built on the spatial grid in the medium, in the simulation's + frame, which it sets to the comoving frame when none was given. - The integrator, the controller and the collar scheme each come from - :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` when not given. A default - carried by a copy is resolved again, so it follows the copy's route and - planes; a value the caller gave is kept. + - ``initial_z``: the launch position, m. + - Returns: the spectrum, complex128, read-only and the simulation's own + (`_built`). """ - carried = ( - self.integrator is not None and self.integrator is self._default_integrator - ) - object.__setattr__( - self, "integrator_named", self.integrator is not None and not carried - ) - if not self.integrator_named: - default = default_integrator(self._route) - if carried and type(self.integrator) is type(default): - default = self.integrator - object.__setattr__(self, "integrator", default) - object.__setattr__(self, "_default_integrator", default) - controller = self.controller - defaulted = controller is not None and controller is self._default_controller - if controller is None and self._route is not None: - controller = default_controller(self._route) - defaulted = True - elif defaulted and controller is not None: - # A default copied into another route takes that one's tolerance, - # keeping the absolute tolerance the route's builder chose. - rtol = ( - default_rtol(self._route) - if self._route is not None - else controller.rtol - ) - if rtol != controller.rtol: - controller = dataclasses.replace(controller, rtol=rtol) - object.__setattr__(self, "controller", controller) - object.__setattr__(self, "controller_defaulted", defaulted) - if defaulted: - object.__setattr__(self, "_default_controller", controller) - if self.absorber_scheme is None or isinstance(self.absorber_scheme, _Defaulted): - scheme = default_scheme( - self._route, interpolate_planes=self.interpolate_planes + if not isinstance(initial, SpatialPulse | SpatialPulseSum): + return _built(initial, grid) + if self.spatial_grid is None or self.medium is None: + msg = "a SpatialPulse requires spatial_grid and medium" + raise DriverError(msg) + if self.frame is None: + self.frame = Frame.comoving( + grid, thg=declares_harmonic_carrier(self.responses, grid) ) - object.__setattr__(self, "absorber_scheme", _Defaulted(scheme)) + return _built_state( + initial, + initial.build( + grid, + self.spatial_grid, + medium=self.medium, + z=initial_z, + frame=self.frame, + ), + ) @property - def absorber_scheme_named(self) -> bool: # numpydoc ignore=RT01 - """Whether the caller named the collar scheme, rather than taking the route's. + def settings(self) -> SimulationSettings: # numpydoc ignore=RT01 + """How the run steps, records and executes; fixed once translated. - False when :attr:`absorber_scheme` is the route's default. - """ - return not isinstance(self.absorber_scheme, _Defaulted) + Assigning a new :class:`~solprop.driver.settings.SimulationSettings` before the + first :meth:`translate` (or :meth:`run`) takes effect. After it the translated + problem is cached, so an assignment that changes anything the translation + consumed is refused, because it could no longer change the run; the ``backend`` + and ``tdse_execution`` resources, which are read when the run executes, may + still change. - def execution_options(self) -> ExecutionOptions: - """Return the execution options a backend is prepared with (SEP 47). + The settings the run is, or will be, translated with. + """ + return self._settings - The node budget is an execution choice, not part of the scientific problem: - the translation records none, and the backend plans its node chunk from it - when it is prepared (:func:`solprop.backends.node_chunk.plan_node_chunk`). + @settings.setter + def settings(self, settings: SimulationSettings) -> None: + """Replace settings; after translation, execution resources alone. - Returns - ------- - solprop.backends.session.ExecutionOptions - The explicit :attr:`node_budget`, or the backend's automatic choice. + Parameters + ---------- + settings : SimulationSettings + The replacement. - Examples - -------- - >>> from solprop.driver import SimulationSettings - >>> settings = SimulationSettings( - ... save_planes=[1.0], dz_max=0.1, dz_init=0.01, node_budget=2**26 - ... ) - >>> settings.execution_options().node_budget - 67108864 + Raises + ------ + DriverError + If the simulation has already been translated and the replacement changes + a setting other than ``backend``, ``resume_history``, ``fields``, + ``field_planes``, ``tdse_execution`` or ``boundary_check``, naming them. """ - return ExecutionOptions(node_budget=self.node_budget) + if self._problem is not None: + changed = [ + one.name + for one in dataclasses.fields(SimulationSettings) + if one.name not in _EXECUTION_SETTINGS + and not one.name.startswith("_") + and not _same_setting( + getattr(self._settings, one.name), getattr(settings, one.name) + ) + ] + if changed: + msg = ( + f"this Simulation is already translated, so a new " + f"{', '.join(changed)} would be ignored; set the settings before " + "the first translate() or run(), or build a new Simulation (only " + "the backend, resume_history, fields, field_planes, the TDSE " + "execution resources and boundary_check may change after " + "translation)" + ) + raise DriverError(msg) + if self._recipe is not None: + dropped = [ + one.name + for one in dataclasses.fields(SimulationSettings) + if one.name not in _RECIPE_EXECUTION + and not one.name.startswith("_") + and not _same_setting( + getattr(self._settings, one.name), getattr(settings, one.name) + ) + ] + if dropped: + self._drop_recipe(f"its {', '.join(dropped)} changed") + if hasattr(self, "noise"): + _check_initial_position(self, settings) + _check_tdse_execution(self, settings) + if _quiet_collar(settings) != _quiet_collar(self._settings): + # The opt-in is part of the resume key: the record is built again. + self._metadata = None + self._settings = settings + + def _kind(self) -> str: + """Return what kind of run this is, for printing.""" + if self.spatial_grid is not None: + return type(self.spatial_grid).__name__ + count = len(self.modes) + if self.general is not None: + return f"{count} guide channel" + ("" if count == 1 else "s") + return f"{count} mode" if count == 1 else f"{count} modes" - def defaults_resolution(self) -> DefaultsResolution: - """Return which stepping choices took the route's default (#87, SEP 41). + def __repr__(self) -> str: + """Return a one-line description: the grid, the geometry and the responses. Returns ------- - solprop.output.schema.DefaultsResolution - The route, and which of the integrator, the relative tolerance and the - collar scheme came from its row of the defaults table. - - Examples - -------- - >>> from solprop.driver import SimulationSettings - >>> settings = SimulationSettings( - ... save_planes=[1.0], dz_max=0.1, dz_init=0.01, backend="numpy_ref" - ... ) - >>> settings.defaults_resolution() - DefaultsResolution(route=None, defaulted=('integrator', 'absorber_scheme')) + str + For example ``Simulation(FieldGrid(...), 2 modes, responses=[Kerr, Plasma], + backend=auto)``. """ - defaulted = ( - *(() if self.integrator_named else ("integrator",)), - *(("rtol",) if self.controller_defaulted else ()), - *(() if self.absorber_scheme_named else ("absorber_scheme",)), + responses = ", ".join(type(response).__name__ for response in self.responses) + return line( + "Simulation", + [ + ("", repr(self.grid)), + ("", self._kind()), + ("responses", f"[{responses}]"), + ("backend", self.settings.backend), + ], ) - return DefaultsResolution(self._route, defaulted) + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. -def _admit_tdse_execution(settings: SimulationSettings) -> None: - """Refuse TDSE execution resources beside settings a TDSE run cannot step with. - - Each refusal names the setting at fault, in the order a caller would fix them: - the backend, then the integrator. - """ - if not isinstance(settings.tdse_execution, TDSEExecution): - msg = ( - "tdse_execution must be a TDSEExecution, got " - f"{type(settings.tdse_execution).__name__}" - ) - raise DriverError(msg, parameter="tdse_execution") - if settings.backend != "numpy_ref": - msg = ( - f"tdse_execution requires an explicit backend='numpy_ref', the one backend " - f"that runs the TDSE response; got backend={settings.backend!r}" - ) - raise DriverError( - msg, parameter=("tdse_execution", "backend"), rule="tdse-numpy-ref" + - Returns: the title and ``(key, value)`` rows. + """ + names = [type(response).__name__ for response in self.responses] + initial = ( + summarise(self.initial) + if isinstance(self.initial, np.ndarray) + else repr(self.initial) ) - if not isinstance(settings.integrator, Multistep): - msg = ( - "tdse_execution requires integrator=Multistep(), the Adams method the TDSE " - f"response steps with; got {type(settings.integrator).__name__}()" + rows: list[tuple[str, object]] = [("grid", repr(self.grid))] + if self.spatial_grid is not None: + rows.append(("space", repr(self.spatial_grid))) + if self.general is not None: + rows.append(("guide", repr(self.general))) + rows.extend( + (f"mode {index}", repr(mode)) for index, mode in enumerate(self.modes) ) - raise DriverError( - msg, parameter=("tdse_execution", "integrator"), rule="tdse-multistep" + rows.extend( + [ + ( + "responses", + ", ".join(names) + or ("the guide's regions" if self.general is not None else "none"), + ), + ("input", initial), + ( + "noise", + ( + "none" + if self.noise_resolution.reason is None + else f"none ({self.noise_resolution.reason})" + ) + if self.noise is None + else f"{self.noise.model} model, seed {self.noise.seed}", + ), + ("settings", str(self.settings)), + ] ) + return f"Simulation: {self._kind()}", rows + def translate(self) -> PropagationProblem: + """Assemble the kernel problem, once, and return it. -def _quiet_collar(settings: SimulationSettings) -> bool: - """Return whether a run's settings opt in to the quiet collar (ADR-057). - - - Returns: True when the boundary check asks for it. - """ - check = settings.boundary_check - return check is not None and check.quiet_collar + Everything expensive about a run that does not depend on the state happens here + (``docs/dev/architecture.md`` section 7): the cumulative linear operator, the + masks, the modal transform and the compiled responses. The result is cached, so + two runs of the same simulation translate once. + Returns + ------- + solprop.translate.problem.PropagationProblem + Validated, versioned and ready for a backend. -def _keyword_controller( - route: str, rtol: float | None, atol: float -) -> tuple[PIController, PIController | None]: - """Return a keyword interface's controller, and it again if its rtol is default. - - - ``route``: the row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the - interface reads. - - ``rtol``: the caller's relative tolerance, which is never raised silently; - ``None`` takes the route's (:func:`~solprop.driver.defaults.default_rtol`). - - Returns: the controller, and the same object where `rtol` was the route's default - so that the settings record the default, else ``None``. - """ - if rtol is not None: - return PIController(rtol=rtol, atol=atol), None - controller = PIController(rtol=default_rtol(route), atol=atol) - return controller, controller - - -def _stepper_record(settings: StepperSettings) -> StepperRecord: - """Return the run's stepper record, naming an Adams integrator where one steps it. + Examples + -------- + >>> from solprop.driver import Simulation + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + ... ) + >>> problem = simulation.translate() + >>> problem.shape, simulation.translate() is problem + ((1, 1, 768), True) + """ + if self._problem is None: + settings = self.settings + if ( + self._reuse is None + and self.spatial_grid is None + and self.general is None + and self.wall is None + and self.taper_coupling + ): + _warn_adiabatic_taper(self.modes) + with _optmat.material_context(self._material_log): + self._problem, self._request = assemble( + self.grid, + self.modes if self.spatial_grid is None else None, + list(self.responses), + self._field0, + initial_z=self.initial_z, + # Simulation.__init__ refuses a missing controller. + controller=cast("PIController", settings.controller), + save_planes=settings.save_planes, + dz_max=settings.dz_max, + dz_init=settings.dz_init, + dz_min=settings.dz_min, + stretch=settings.stretch, + auto_init=settings.auto_init, + interval_cap=settings.interval_cap, + interpolate_planes=settings.interpolate_planes, + step_planes=settings.step_planes, + # The contract carries the Runge-Kutta march as its tableau and + # no integrator; an Adams method or the low-storage march as the + # integrator, beside an unused DOPRI5. + tableau=( + settings.integrator.tableau + if isinstance(settings.integrator, RungeKutta) + else DOPRI5 + ), + integrator=( + None + if isinstance(settings.integrator, RungeKutta) + else settings.integrator + ), + schedule=settings.schedule, + propagator_mode=settings.propagator_mode, + density=settings.density, + noise=self.noise, + absorber=settings.absorber, + absorber_scheme=_scheme(settings), + quadrature=settings.quadrature, + components=settings.components, + ionisation_field_range=( + settings.ionisation_field_range if self._reuse is None else None + ), + stats_spec=settings.stats, + shock=settings.shock, + prefactor_index=settings.prefactor_index, + polarisation_taper=settings.polarisation_taper, + spatial_grid=self.spatial_grid, + medium=self.medium, + frame=self.frame, + general=self.general, + wall=self.wall, + taper_coupling=self.taper_coupling, + reuse=self._reuse, + evanescent_rate=settings.evanescent_rate, + spatial_absorber=_transverse_specification( + settings.spatial_absorber, self.spatial_grid + ), + adiabatic_accuracy=settings.adiabatic_accuracy, + ) + return self._problem - The method is recorded in full, and the interval cap and start-up rule with the - limits, so that a run and its resume hash the stepping they were taken with. - """ - return StepperRecord.from_settings( - settings.tableau, - settings.controller, - schedule=settings.schedule, - limits=StepLimits( - dz_min=settings.dz_min, - dz_max=settings.dz_max, - dz_init=settings.dz_init, - stretch=settings.stretch, - interval_cap=settings.interval_cap, - auto_init=settings.auto_init, - interpolate_planes=settings.interpolate_planes, - step_planes=settings.step_planes, - ), - method=None if settings.integrator is None else repr(settings.integrator), - ) + def _run_request(self) -> RunRequest: + """Return the request translation returned with the problem, translating once. + Every policy of a fresh run of :meth:`translate`'s problem: the method, the + error control, the itinerary, the planes and the statistics (SEP 47 section + 5.2). A run adapts it to its start, retention, monitor and history + (:meth:`~solprop.translate.run_request.RunRequest.for_run`). + """ + self.translate() + assert self._request is not None # set with the problem + return self._request -LOW_STORAGE_BACKENDS = ("native", "numpy_ref") -"""The backends that execute :class:`~solprop.stepper.low_storage.LowStorageRungeKutta`. + def input_port(self, z: float | None = None) -> InputPort: + """Return what this simulation can receive at its entrance (SEP 31). -``numpy_ref`` from SEP 24's phase 1 and solprop-native from phase 3, on every route the -translation admits the march on; ``backend="auto"`` resolves as for every other -integrator (:func:`solprop.backends.registry.resolve_name`: the native backend). -""" + The port is the receiving side of a transfer between simulations: its route, + temporal lattice and basis with their identities, the entrance plane and the + frame accumulated there, the native metric at the entrance, the reference + index of its entrance medium, the coupling models it admits and what they + need: an ordinary mode set's scalar profiles, or a general guide's E/H + reference at its nodes and its regions' contrast. A launch computed for it + (:func:`solprop.coupling.coupler.couple`) is accepted by :meth:`with_input`, + and a launch computed for any other port is refused. + Parameters + ---------- + z : float or None, optional + An interior entrance in m, for a modal run continued from inside its own + path; None is the simulation's launch position. -def _admit_low_storage( - integrator: LowStorageRungeKutta, backend: str, settings: SimulationSettings -) -> str: - """Refuse what the low-storage march cannot run, before translation (SEP 24 5). + Returns + ------- + solprop.coupling.InputPort + The port and the models it admits. - The march replays a prescribed schedule, lands on its edges and accepts every - step, so it needs a schedule and has no interpolated planes. Every registered - backend executes it (:data:`LOW_STORAGE_BACKENDS`), so the backend passes as asked. - """ - if settings.schedule is None: - msg = ( - f"the {integrator.name} march replays a prescribed schedule; pass " - "schedule=FixedSchedule.uniform(...), or step adaptively with RungeKutta()" - ) - raise DriverError( - msg, parameter=("integrator", "schedule"), rule="low-storage-needs-schedule" + Raises + ------ + DriverError + For a hybrid planar run, whose slab columns are not exchanged in this + release, or an interior entrance outside the run. + """ + problem = self.translate() + metadata = self.metadata() + reference = cast("Mapping[str, object]", metadata.reference) + bases = cast("Mapping[str, object]", reference["basis"]) + route = cast("str", bases["route"]) + if route == "planar": + msg = ( + "a hybrid planar run's columns are slab amplitudes per y frequency; " + "planar planes are not exchanged in this release" + ) + raise DriverError(msg) + if z is not None and not ( + math.isfinite(z) and 0.0 <= z < float(np.max(self.settings.save_planes)) + ): + msg = f"an interior entrance lies inside the run, got z = {z!r} m" + raise DriverError(msg) + identity = cast("list[str]", bases["identity"])[0] + if route == "modal" and z is not None: + identity = modal_basis_identity(self.modes, self.grid, z) + labels = tuple(mode.channel_label.display_label for mode in self.modes) + space = ( + self.spatial_grid + if isinstance(self.spatial_grid, RadialGrid | FreeGrid) + else None ) - if settings.interpolate_planes: - msg = ( - f"the {integrator.name} march lands on its schedule's edges and has no " - "dense output; drop interpolate_planes" + basis = PlaneBasis( + cast("Literal['modal', 'general_modal', 'radial', 'cartesian']", route), + identity, + labels, + space, ) - raise DriverError( - msg, - parameter=("integrator", "interpolate_planes"), - rule="low-storage-no-interpolation", + return fill_port( + problem, + self.grid, + route=basis.kind, + basis=basis, + frame=cast("dict[str, object] | None", reference["frame"]), + medium=self.medium, + tables=None if self.general is None else self.general.tables, + z=z, + modes=tuple(self.modes) if route == "modal" else (), + guide=self.guide if isinstance(self.guide, ModalGuide) else None, ) - return backend - -def _admit_integrator(integrator: Multistep, backend: str) -> None: - """Refuse an Adams integrator on a backend that does not execute it. - - Refused here, before translation, so that no backend -- in particular a - separately built one reading the contract by name -- can silently step the problem - with its Runge-Kutta march instead (SEP 13 section 6.1). - """ - if backend not in ("auto", "numpy_ref", "native"): - msg = ( - f"the {integrator.name} integrator is implemented on numpy_ref and native " - f"only, and backend {backend!r} would step the problem with its " - "Runge-Kutta march; pass one of those or 'auto', or " - "integrator=RungeKutta()" - ) - raise DriverError( - msg, parameter=("integrator", "backend"), rule="integrator-backends" - ) + def with_input( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray | CouplingLaunch, + *, + noise: NoiseRealisation | Literal["keep"] | None = "keep", + initial_z: float | None = None, + configuration: Mapping[str, object] | None = None, + input_identity: str | None = None, + ) -> "Simulation": + """Replace the input while sharing the translated propagation problem. + Only what changed is validated (SEP 22 section 5.2): the replaced input's + shape, dtype and one finiteness pass, and the replaced noise's, with every + other leaf of the translated problem shared by identity rather than scanned + again. A new `initial_z`, which changes the save planes, also keeps the + stepper's checks: the launch before the first plane, and a replayed schedule + containing every plane. An array input above + :data:`~solprop.output.schema.INLINE_ARRAY_LIMIT` elements is recorded by its + digest (:class:`~solprop.output.schema.ArrayDigest`), not inline, unless its + identity is stated (`input_identity`). -def metadata_for( - problem: PropagationProblem, - request: RunRequest, - grid: TimeGrid, - *, - modes: Sequence[Mode] = (), - configuration: Mapping[str, object] | None = None, - noise: NoiseRealisation | None = None, - modal: Mapping[str, JsonValue] | None = None, - materials: MaterialManifest | None = None, - quadrature: TransverseQuadrature | None = None, - frame: Frame | None = None, - noise_resolution: NoiseResolution | None = None, - defaults: DefaultsResolution | None = None, -) -> RunMetadata: - """Build the record that says what produced a run, and whether it may be resumed. - - Everything that describes *what is computed* goes into the digest - :meth:`solprop.output.schema.RunMetadata.configuration_hash` takes -- the grid, the - modes, the configuration, the noise and the stepper settings, plus the save planes - and the statistics, which are read off the run's request here -- and nothing that - describes *when* or *with which build*, so a resume survives an unrelated commit or - a NumPy upgrade. + Parameters + ---------- + initial : Pulse, SpatialPulse, numpy.ndarray or solprop.coupling.CouplingLaunch + Pulse or internal-convention spectrum on the existing grid. The built + shape must match the original input, including modes and polarisation. + Modal amplitudes are in sqrt(W); spatial units follow the spatial grid. + An array is copied when the derived simulation is built, as by the + constructor. + A :class:`~solprop.coupling.port.CouplingLaunch` (SEP 31) must be bound to + this simulation's :meth:`input_port` -- its route, lattice, basis, + entrance and metric -- or it is refused whatever its shape; its + entrance sets the launch position, and its provenance and inherited + reference origin are recorded as ``configuration["coupling"]``. + noise : NoiseRealisation, {'keep'} or None, optional + Keep the existing noise, replace it explicitly, or disable it with None. + Noise spectra must match the existing grid and input shape. A launch + carries the realised field of its source, so for a launch ``'keep'`` + means no noise: no second floor is drawn unless a realisation is given. + Kept noise that was drawn without a seed can still be drawn again from + the seed a resume's file or a scan point supplies. + initial_z : float or None, optional + Absolute launch position in metres. None retains this simulation's + launch. An explicit position keeps only existing save planes beyond + it, preserving all absolute profiles, propagation tables and the frame. + configuration : collections.abc.Mapping or None, optional + Caller keys merged into the derived run's configuration record, and so + into its configuration hash: the delay of a scan, the label of an input. + A key the run already records is refused. + input_identity : str or None, optional + What an array input is, stated by the caller who already knows: the record + then holds this string, the dtype and the shape + (:class:`~solprop.output.schema.StatedInput`) in place of the array's own + record, and its bytes are not digested. **It is not checked**: two + different arrays stated alike have one configuration hash, so a resume or + a scan's cache accepts one for the other. ``None``, the default, records + the array as itself or by its digest. Refused for anything but an array. - Parameters - ---------- - problem : solprop.translate.problem.PropagationProblem - The translated problem the run will step. - request : solprop.translate.run_request.RunRequest - The request translation returned with it: its stepper settings, save planes - and statistics enter the hash exactly as they did when the problem carried - them. - grid : solprop.grid.TimeGrid - The grid the state lives on. - modes : collections.abc.Sequence, optional - The propagated modes, in state order. - configuration : collections.abc.Mapping, optional - The physics objects that built the run -- responses, the input, profiles, - boundaries -- recorded by :func:`solprop.output.schema.serialise`. Nothing is - dropped: a callable is recorded as its ``repr`` and marked unreproducible, which - also makes the digest unstable across processes, so prefer objects that carry - their data (``docs/guide/setup/running.md``). - noise : solprop.noise.NoiseRealisation or None, optional - The realisation the run carries, recorded with its seed. - modal : collections.abc.Mapping or None, optional - The general modal identity, as - :func:`solprop.translate.general.modal_identity` writes it: the channel - labels, the table, metric, response and guide identities, the frame, the - active band and the accessors the basis admits. ``None`` for every other - route. It is part of the resume key. - materials : solprop.physdata.selection.MaterialManifest or None, optional - The run's material manifest - (:func:`solprop.driver.material_output.material_manifest`); ``None`` is the - empty manifest of a run of solprop's own materials. Its identities and - policies are part of the resume key. - quadrature : solprop.modes.quadrature.TransverseQuadrature or None, optional - The transverse rule of a resolved modal run, recorded with its nodes and - physical weights on the first mode's cross-section and part of the resume - key (SEP 19). ``None`` for every other route. - frame : solprop.linear.Frame or None, optional - The run's frame, whose reference frequency is the origin of the linear frame - term on an envelope grid; ``None`` is the grid's co-moving frame. It enters - the plane-reference record (SEP 31), not the hash. - noise_resolution : solprop.output.schema.NoiseResolution or None, optional - How the noise model was chosen (SEP 37). Outside the hash. - defaults : solprop.output.schema.DefaultsResolution or None, optional - Which stepping choices took the route's default. Outside the hash. + Returns + ------- + Simulation + Translated simulation sharing all propagation tables. Metadata is + rebuilt for the replacement input and noise. Existing ionisation + table ranges remain in force, and so does the reference field of an + optmat rate's constant energy per event, which is the original + input's peak; use a fresh translation to change them. - Returns - ------- - solprop.output.schema.RunMetadata - The record, ready for :meth:`solprop.output.schema.Output.open`. + Raises + ------ + DriverError + If the new field or noise shape differs or any sample is not finite, a + `configuration` key is one the run records itself, a launch is bound + to another port or given beside an `initial_z`, or `input_identity` is + empty or names anything but an array input. - Examples - -------- - >>> from solprop.driver.simulation import metadata_for - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.nonlinear.gnlse import problem_from - >>> from solprop.pulses import SechPulse - >>> from solprop.stepper import PIController - >>> from solprop.translate.problem import assemble - >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1e-12) - >>> gnlse = problem_from([0.0, 0.0, -1.2e-26], omega_ref=grid.omega0, gamma=0.11) - >>> start = SechPulse(wavelength=835e-9, width=1e-13, peak_power=1.0).build(grid) - >>> problem, request = assemble(grid, gnlse.mode, [gnlse.kerr], start, - ... controller=PIController(rtol=1e-8, atol=1e-14), - ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3, - ... density=1.0, shock=False, prefactor_index=1.0) - >>> metadata = metadata_for(problem, request, grid, modes=[gnlse.mode]) - >>> metadata.simulation_kind, len(metadata.configuration_hash()) - ('mode_averaged', 64) - """ - settings = request.stepper - record = dict(configuration or {}) - if bool(problem.responses.plasma_channels): - # The equation and admission policy are identity; the measured residual - # belongs to backend execution evidence, like the TDSE certificate. Its - # last bits may change with the host FFT implementation. - source = plasma_source_record(problem.grids) - identity = {key: value for key, value in source.items() if key != "certificate"} - if _BENCHMARK_GATED.get(): - identity["form"] = "gated_benchmark" - record["_benchmark_plasma_source"] = "gated_nonresumable" - record["plasma_source"] = identity - # The planes and the statistics come from the request rather than from the caller: - # a resume continues at a plane index, so two runs that differ in either are not the - # same run, and neither is carried by StepperRecord. - record["save_planes"] = np.asarray(settings.save_planes, dtype=np.float64) - record["statistics"] = request.observation.stats - measure = transverse_measure(modes) if modes else "area" - geometry = problem.transform.geometry - if geometry is not None and geometry.kind == "free" and geometry.shape[1] == 1: - # A Cartesian line (one y row of unit measure) is per metre of y however the - # simulation was built, not only through from_free. - measure = "per_unit_width" - if measure != "area": - # A run uniform in y reports its powers per metre of y; processing and - # plotting label them from this key (RunMetadata.transverse_measure). It is - # written only where it is not the default, so every area run keeps the - # digest it was stamped with. - record["transverse_measure"] = measure - if problem.responses.envelope_plasma is not None: - record["statistic_rate_conventions"] = { - name: "cycle-averaged" - for name in ("peak_ionisation_rate", "species_peak_ionisation_rate") - if name in request.observation.stats.names - or name in request.observation.stats.periodic - } - record["envelope_plasma"] = assembly_record( - problem.responses.envelope_plasma, problem.grids.omega + Warns + ----- + MaterialRangeWarning + Once per run, when the new input's peak exceeds an optmat rate's maximum + field, or the input is not linearly polarised at a Keldysh parameter + above one there (SEP 3, D12). + """ + if isinstance(noise, str) and noise != "keep": + raise DriverError("noise must be keep, None, or a NoiseRealisation") + _check_input_identity(input_identity, initial) + coupling = None + if isinstance(initial, CouplingLaunch): + launch = initial + initial, initial_z, noise = self._launched(launch, initial_z, noise) + coupling = launch.record() + original = self.translate() + launch_z = self.initial_z if initial_z is None else initial_z + settings = self.settings + if initial_z is not None: + remaining = np.asarray(settings.save_planes) + remaining = remaining[remaining > launch_z] + if not np.isfinite(launch_z) or launch_z < 0 or remaining.size == 0: + raise DriverError( + "initial_z must be finite, nonnegative and before the end" + ) + settings = dataclasses.replace(settings, save_planes=remaining) + replacement_noise = self.noise if isinstance(noise, str) else noise + resolution = ( + self.noise_resolution + if isinstance(noise, str) or noise is self.noise + else NoiseResolution( + None if noise is None else noise.model, + None if noise is None else noise.model, + ) + ) + if ( + isinstance(noise, str) + and launch_z > 0 + and self.noise_resolution.requested == "default" + and self.noise is not None + ): + # SEP 37: a default draw is not added at an interior launch, whose given + # spectrum may already carry the realisation of an earlier run. + replacement_noise = None + resolution = NoiseResolution("default", None, INTERIOR_LAUNCH_NOISE) + # An array is refused before the derived simulation copies it. + self._check_replaced_array( + initial, None if isinstance(noise, str) else replacement_noise + ) + updated = Simulation( + self._base_grid, + self.modes, + self.responses, + initial, + settings=settings, + initial_z=launch_z, + noise=replacement_noise, + noise_resolution=resolution, + profiles=self.profiles, + spatial_grid=self.spatial_grid, + medium=self.medium, + frame=self.frame, + general=self.general, + guide=self.guide, + wall=self.wall, + taper_coupling=self.taper_coupling, + materials=self.materials, ) - if problem.responses.plasma_integral.policy != "trapezoid": - # The band rule's taps and policies (SEP 18, Q9): a resume that would - # integrate the plasma differently is refused through the hash. - record["plasma_integration"] = integration_record( - problem.responses.plasma_integral + # Recorded from what the derived run holds, not from the caller's input. + updated.configuration = self._derived_configuration( + updated.initial, configuration, coupling ) - material = record.get("medium") - if isinstance(material, SpatialMedium): - record["spatial_medium"] = material - material = material.material - if not isinstance(material, Mixture) and modes: - material = getattr(modes[0], "mixture", None) - if isinstance(material, Mixture): - record["medium"] = material_record(material) - record["mixture_processing"] = processing_record( - problem, request.observation.save_planes, grid, material, modes + updated._input_identity = input_identity + # A derived run starts from what its parent's run has warned and recorded, + # and keeps its own from here: its siblings never see each other's. + updated._material_log = self._material_log.copy() + if isinstance(noise, str) and replacement_noise is not None: + updated._noise_redraw = self._noise_redraw + if self._recipe is not None: + self._warn_recipe_dropped("with_input") + if not isinstance(initial, np.ndarray): + self._check_replaced_input( + updated._field0, None if isinstance(noise, str) else replacement_noise + ) + record = dataclasses.replace( + original.initial, + z=launch_z, + field0=_replaced_launch(original, updated._field0, launch_z), ) - if problem.linear.separable is not None: - # A Cartesian checkpoint must not resume with changed cutoff bins. - record["spatial_arithmetic"] = "split-square-v1" - geometry = problem.transform.geometry - spatial_record: dict[str, object] | None = None - if geometry is not None: - # A hybrid planar column is a slab amplitude per y frequency: its x axis is the - # slab rule's nodes, its spectrum the mode index and k_y, and its stored unit a - # modal amplitude's. - slab = geometry.kind == "slab" - spatial_record = { - "kind": geometry.kind, - "shape": geometry.shape, - "axes": geometry.axes, - "axis_names": ("r",) if geometry.kind == "radial" else ("x", "y"), - "axis_units": "m", - "spectral_axes": geometry.spectral_axes, - "spectral_axis_names": ("k_r",) - if geometry.kind == "radial" - else ("k_y",) - if slab - else ("k_x", "k_y"), - "spectral_axis_units": "rad/m", - "column_order": "r" - if geometry.kind == "radial" - else "p + n_p*iy" - if slab - else "ix + n_x*iy", - "physical_field_units": "V/m", - "stored_field_units": "V m" - if geometry.kind == "radial" - else "sqrt(W/m)" - if slab - else "V/m", - "spatial_weights": geometry.spatial_weights, - "spectral_weights": geometry.spectral_weights, - "forward_scale": geometry.forward_scale, - "inverse_scale": geometry.inverse_scale, - "window": geometry.window, - "absorber_rate": geometry.absorber_rate, - "flux_model": "scalar-axial-admittance", - "flux_plan": flux_record(flux_of(problem.route)), - } - descriptor = tdse_descriptor(problem.route) - tdse_record = None if descriptor is None else dataclasses.asdict(descriptor) - design = transverse_of(problem.route) - return RunMetadata( - reference=_plane_reference_record( - problem, request, grid, record, frame=frame, modes=modes, modal=modal - ), - transverse=None if design is None else design.record(), - grid=GridRecord.from_grid(grid), - modes=tuple(ModeRecord.from_mode(mode) for mode in modes), - simulation_kind=( - geometry.kind - if geometry is not None - else "mode_averaged" - if problem.flags.mode_averaged - else "multimode" - ), - configuration=record, - stepper=_stepper_record(settings), - noise=None if noise is None else NoiseRecord.from_realisation(noise), - noise_resolution=noise_resolution, - defaults=defaults, - spatial=spatial_record, - quadrature=None - if quadrature is None or not modes - else quadrature.record(modes[0].dimlimits(0.0)), - modal=None if modal is None else dict(modal), - materials=MaterialManifest() if materials is None else materials, - initial_z=problem.initial.z, - tdse=tdse_record, - ) - - -def _frame_follows_the_electronic_rule( - responses: Sequence[object], grid: TimeGrid -) -> bool: - """Return whether a run's co-moving frame changed when it stopped reading Raman. - - Before the frame followed - :func:`solprop.translate.responses.declares_harmonic_carrier`, any response's - ``thg`` kept ``beta_0`` on an envelope grid. The two rules disagree exactly when a - response other than the electronic ones declares ``thg=True`` and no electronic one - does. - - - Returns: True when the earlier rule would have kept ``beta_0`` and this one - removes it. - """ - return ( - grid.kind == "envelope" - and any(bool(getattr(one, "thg", False)) for one in responses) - and not declares_harmonic_carrier(responses, grid) - ) - - -def _plane_reference_record( - problem: PropagationProblem, - request: RunRequest, - grid: TimeGrid, - configuration: Mapping[str, object], - *, - frame: Frame | None, - modes: Sequence[Mode], - modal: Mapping[str, JsonValue] | None, -) -> dict[str, object]: - """Return the plane-reference record of a run (SEP 31). - - - ``configuration``: the run's configuration; a ``coupling`` entry supplies the - inherited origin and the parent's provenance, and a ``spatial_grid`` the - transverse basis. - - ``frame``: the run's frame, or ``None`` for the co-moving one. - - Returns: ``carrier``, ``origin``, ``parent``, ``frame`` and ``basis``, as - :attr:`solprop.output.schema.RunMetadata.reference` documents them. - """ - # The co-moving frame keeps the carrier phase exactly when a harmonic term is - # present, and then measures its delay from zero frequency (`frame_origin`). - # `assemble` chose it from the declared responses and checked that choice against - # the compiled carrier, so reading the compiled one here records the same frame. - if frame is None: - frame = Frame.comoving(grid, thg=problem.responses.harmonic_carrier) - coupling = configuration.get("coupling") - origin: object = PlaneReference().record() - parent: object = None - if isinstance(coupling, Mapping): - coupled = cast("Mapping[str, object]", coupling) - origin = coupled["origin"] - parent = coupled["source"] - return { - "carrier": float(grid.omega0), - "origin": origin, - "parent": parent, - "frame": frame_record(problem.linear, frame_origin(frame, grid.kind)), - "basis": _plane_bases(problem, request, grid, configuration, modes, modal), - } - - -def _plane_bases( # noqa: PLR0917 - called positionally - problem: PropagationProblem, - request: RunRequest, - grid: TimeGrid, - configuration: Mapping[str, object], - modes: Sequence[Mode], - modal: Mapping[str, JsonValue] | None, -) -> dict[str, object]: - """Return a run's route and its basis identity at each planned plane. - - A spatial basis is its transverse grid and a general one its checked reference, - the same at every plane; an ordinary mode set's basis is evaluated at each plane - (:func:`solprop.coupling.plane.modal_basis_identity`), because a guide may vary, - and once for a set whose every mode is ``z_constant``. - - - ``configuration``: the run's configuration, carrying a spatial run's - ``spatial_grid``. - - Returns: ``route``, ``z`` (the launch plane first, then the save planes, m) and - ``identity`` (one digest per plane, ``None`` on a planar run). - """ - planes = [ - float(problem.initial.z), - *map(float, request.observation.save_planes), - ] - geometry = problem.transform.geometry - identity: str | None - if geometry is not None: - route = {"radial": "radial", "free": "cartesian", "slab": "planar"}[ - geometry.kind - ] - space = configuration.get("spatial_grid") - identity = ( - basis_identity(cast("Literal['radial']", route), space_record(space)) - if isinstance(space, RadialGrid | FreeGrid) - else None + updated._problem = _with_noise( + dataclasses.replace(original, initial=record), replacement_noise ) - identities = [identity] * len(planes) - elif modal is not None and modal.get("basis") == "general_modal": - route = "general_modal" - metric = cast("Mapping[str, JsonValue]", modal.get("metric") or {}) - record = { - "channels": modal.get("channels"), - "tables": modal.get("tables"), - "guide": modal.get("guide"), - "metric": {"kind": metric.get("kind"), "gauge_id": metric.get("gauge_id")}, - } - identities = [basis_identity("general_modal", record)] * len(planes) - elif all(mode.z_constant for mode in modes): - # A guide that is the same at every plane has one basis there: evaluated once, - # since a solved mode (a step-index fibre's) costs a mode solve per evaluation. - route = "modal" - identities = [modal_basis_identity(modes, grid, planes[0])] * len(planes) - else: - route = "modal" - identities = [modal_basis_identity(modes, grid, z) for z in planes] - return {"route": route, "z": planes, "identity": identities} + request = self._run_request() + updated._request = request.with_stepper( + dataclasses.replace(request.stepper, save_planes=settings.save_planes) + ) + route = updated._problem.route + if isinstance(route, SpatialRoute): + updated._problem = dataclasses.replace( + updated._problem, + route=dataclasses.replace( + route, + transverse=_rechecked( + self, + original, + updated._problem, + updated._request.observation.save_planes, + ), + ), + ) + if initial_z is not None: + # A new launch changes the save planes: the launch must precede them, and + # a replayed schedule contain them (SEP 22 section 5.2). + updated._problem.validate_launch(updated._request) + updated._mixture_selection = self._mixture_selection + with _optmat.material_context(updated._material_log): + _check_rate_inputs(updated, updated.initial) + return updated + def _launched( + self, + launch: CouplingLaunch, + initial_z: float | None, + noise: NoiseRealisation | Literal["keep"] | None, + ) -> tuple[np.ndarray, float | None, NoiseRealisation | None]: + """Return what a coupling launch means for :meth:`with_input`, or refuse it. -def _one_material_context[**P, R](build: Callable[P, R]) -> Callable[P, R]: - """Run a constructor inside one material evaluation context, the run's own. + - ``noise``: the noise asked for: a launch carries its source's realised field, + so ``'keep'`` draws no second floor. + - Returns: the launch's spectrum, its interior position (``None`` at this run's + own entrance) and its noise. + """ + if initial_z is not None: + msg = "a launch carries its own entrance: give no initial_z beside it" + raise DriverError(msg) + entrance = launch.port.geometry.z + interior = None if entrance == self.initial_z else entrance + refusal = self.input_port(interior).accepts(launch.port) + if refusal is not None: + msg = f"this launch was computed for another input port: {refusal}" + raise DriverError(msg) + return launch.internal(), interior, None if isinstance(noise, str) else noise - The simulation it builds adopts the context's log - (:class:`solprop.physdata.selection.MaterialLog`), and its translation, record and - run resume it, so that every range, status and dilute-scaling warning of its - materials is raised once per run, however its work is spread in time. - """ + def _derived_configuration( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + configuration: Mapping[str, object] | None, + coupling: Mapping[str, object] | None = None, + ) -> dict[str, object]: + """Return a derived run's extra configuration: this run's, the caller's keys. - @functools.wraps(build) - def inside(*args: P.args, **kwargs: P.kwargs) -> R: - """Build the simulation with the run's material context open.""" - with _optmat.material_context(): - return build(*args, **kwargs) + - ``coupling``: the provenance of a coupling launch, recorded as ``coupling``. + """ + recorded = dict(self.configuration) + # Nor does the constructor's incident-beam account describe it, nor a + # coupling that launched this run. + recorded.pop("launch", None) + recorded.pop("coupling", None) + # Nor does a modal launch describe a replacement input, which is placed as + # given. + recorded.pop("modal_launch", None) + # A constructor's pulse recipe must never describe a replacement spectrum. + # Arrays carry their exact input in metadata, without an inferred recipe. + if "pulse" in recorded: + recorded.pop("pulse") + if not isinstance(initial, np.ndarray): + recorded["pulse"] = initial + if configuration: + clash = sorted(set(configuration) & ({"coupling"} | self._recorded_keys())) + if clash: + msg = ( + f"configuration keys {clash} are recorded by the run itself; " + "name the caller's keys differently" + ) + raise DriverError(msg) + recorded.update(configuration) + if coupling is not None: + recorded["coupling"] = dict(coupling) + return recorded - return inside + def _check_replaced_array( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + noise: NoiseRealisation | None, + ) -> None: + """Validate a replaced array input before it is copied; a pulse is built first. + - ``noise``: a replacement realisation, or ``None`` when the noise is kept or + removed. + """ + if isinstance(initial, np.ndarray): + self._check_replaced_input(initial, noise) -@contextlib.contextmanager -def _about( - parameter: ParameterPath | None = None, - renamed: Mapping[str, str] | None = None, -) -> Iterator[None]: - """Name the keyword a refusal raised in the block concerns (SEP 41, S10). + def _check_replaced_input( + self, + field0: Shaped[np.ndarray, "*shape"], + noise: NoiseRealisation | None, + ) -> None: + """Validate a replaced input alone: its shape and one finiteness pass. - An object built from a keyword's value refuses it by its own argument's name, or by - none; the interface knows which keyword it passed, so it names that one here. The - exception keeps its object, class, message and traceback. + Every other leaf of the derived problem is this one's, shared by identity and + validated when it was translated (SEP 22 section 5.2); the dtype is fixed by + the build. - - ``parameter``: the path given to a :class:`~solprop.exceptions.SolpropError` that - names no parameter; ``None`` leaves it unnamed. - - ``renamed``: the inner call's argument names that differ from the keywords that - supplied them, each mapped to its keyword: a path the error names already is - renamed through it (each path of a conflict), and kept where it has no entry. - """ - try: - yield - except SolpropError as error: - if error.parameter is None: - error.parameter = parameter - elif renamed: - error.parameter = ( - renamed.get(error.parameter, error.parameter) - if isinstance(error.parameter, str) - else tuple(renamed.get(path, path) for path in error.parameter) - ) - raise + - ``noise``: a replacement realisation, or ``None`` when the noise is kept or + removed. + """ + if field0.shape != self._field0.shape: + raise DriverError("with_input requires the existing mode and grid shape") + if not _all_finite(field0): + raise DriverError("with_input requires a finite initial field") + if noise is None: + return + if noise.spectrum.shape != self._field0.shape: + raise DriverError("replacement noise must match the input field shape") + if not _all_finite(noise.spectrum): + raise DriverError("with_input requires a finite noise realisation") + def runner(self) -> "SimulationRunner": + """Return a runner that keeps this translation and one backend for many inputs. -_REISSUED: Final[dict[str | tuple[str, type[Warning], int], int]] = {} -"""The registry of the warnings :func:`_warnings_about` issues again. + Translates (or reuses the cached translation) and constructs the backend once; + each :meth:`~solprop.driver.runner.SimulationRunner.run` then runs one input on + it (SEP 22 section 5.2). Close it, or use it in a ``with`` block. -It stands in for the issuing module's own, so that a ``"default"`` filter still shows -a warning once per location and text rather than once per run. -""" - - -@contextlib.contextmanager -def _warnings_about( - parameter: ParameterPath, categories: tuple[type[SolpropWarning], ...] -) -> Iterator[None]: - """Name the keyword the value warnings issued in the block concern (SEP 41, S10). - - A material model warns deep inside the physics, where the keyword that chose the - material is not known. The block's warnings are recorded and, when it ends, issued - again in order at their own locations and through the caller's filters, so that an - ``"error"`` filter still raises and an ``"ignore"`` one still hides them: a warning - of one of `categories` that names no parameter is given `parameter` first, and - every other warning is issued as it was. - """ - caught: list[warnings.WarningMessage] = [] - try: - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - yield - finally: - for record in caught: - # A recorded warning is an instance of its category, never a string. - message = cast("Warning", record.message) - if isinstance(message, categories) and message.parameter is None: - message.parameter = parameter - warnings.warn_explicit( - message, - record.category, - record.filename, - record.lineno, - registry=_REISSUED, - source=record.source, - ) + Returns + ------- + SimulationRunner + The open runner. + Raises + ------ + DriverError + If the run has a TDSE response. + """ + return SimulationRunner(self) -_MATERIAL_WARNINGS: Final = (MaterialRangeWarning, MaterialStatusWarning) -"""The warnings a material model issues about the conditions it is evaluated at.""" + def _drop_recipe(self, reason: str) -> None: + """Forget the recipe, which no longer describes this simulation, and warn.""" + self._recipe = None + msg = ( + f"this Simulation was built from a recipe, but {reason}, which the recipe " + "does not describe: its output will record no recipe, and resuming it " + "needs this script" + ) + warnings.warn(msg, SolpropWarning, stacklevel=3) -_GRID_KEYWORDS: Final[Mapping[str, str]] = {"reference_wavelength": "wavelength"} -"""The grid constructors' argument names that a keyword route spells otherwise.""" + def _warn_recipe_dropped(self, mutator: str) -> None: + """Warn that a derived simulation does not carry this one's recipe.""" + msg = ( + f"{mutator} derives a simulation the recipe this one was built from does " + "not describe: its output will record no recipe, and resuming it needs " + "the script" + ) + warnings.warn(msg, SolpropWarning, stacklevel=3) -_PULSE_KEYWORDS: Final[Mapping[str, str]] = {"phase": "phases"} -"""The pulse constructors' argument names that a keyword route spells otherwise.""" + def with_mixture(self, mixture: Mixture) -> "Simulation": + """Update material paths while preserving the run's geometry and input. + Rate tables and unchanged undamped Raman kernels are shared by identity. + Expansion terms are reused only for the identical sampled function with + covered parameter ranges; changing fill fractions invalidates a pressure + expansion. Every replacement weight and density or decay table is rebuilt. -def _path(*names: str) -> ParameterPath: - """Return the path of one keyword, or the tuple of several in conflict. + Parameters + ---------- + mixture : Mixture + New paths and populations with the same ordered labels and species data. + Public capillary response selections are retained; changed temperature + or Raman component options rebuild the affected molecular models. - - ``*names``: the keywords, at least one. - """ - return names[0] if len(names) == 1 else names + Returns + ------- + Simulation + A translated simulation sharing the grid, input field, noise realisation, + numerical settings and transverse geometry with this one. Noise drawn + without a seed can still be drawn again from the seed a resume's file or + a scan point supplies; on a spatial route, where the draw is measured by + the medium's flux, it is drawn again now from the same seed on the new + medium. A realisation the caller seeded or supplied is shared unchanged. + Raises + ------ + DriverError + If there is no mixture declaration, or labels, species or unsupported + mode geometries differ. + """ + old = _simulation_mixture(self) + if old.labels != mixture.labels: + raise DriverError( + f"with_mixture requires the same ordered labels: {old.labels}, " + f"got {mixture.labels}" + ) + for before, after in zip(old.components, mixture.components, strict=True): + if serialise(before.species) != serialise(after.species): + raise DriverError( + f"with_mixture cannot change species data for {before.label!r}" + ) + original = self.translate() + declared = ( + self.responses + if self._mixture_selection is None + else self._mixture_selection.build(mixture) + ) + channels = { + channel.label: channel for channel in original.responses.population_channels + } + responses = tuple( + dataclasses.replace( + response, + rate=channels[_population_label(response, mixture)].rate, + preionisation=mixture[ + _population_label(response, mixture) + ].preionisation, + ) + if isinstance(response, PlasmaResponse | EnvelopePlasmaResponse) + else response + for response in declared + ) + _refuse_idle_preionisation(mixture, responses) + modes = tuple(_rebind_mode(mode, mixture) for mode in self.modes) + _warn_boreless_gradient(mixture, _flow_radius(self.modes, None)) + profiles = dict(self.profiles) + profiles.pop("pressure", None) + profiles.update(mixture_profiles(mixture, pressure="pressure" in self.profiles)) + configuration = dict(self.configuration) + configuration["medium"] = mixture + if "capillary" in configuration: + configuration["capillary"] = { + **cast("Mapping[str, object]", self.configuration["capillary"]), + "temperature": mixture.temperature, + } + if "waveguide" in configuration: + configuration["waveguide"] = { + **cast("Mapping[str, object]", self.configuration["waveguide"]), + "modes": [ModeRecord.from_mode(mode) for mode in modes], + "temperature": mixture.temperature, + } + updated = Simulation( + self._base_grid, + modes, + responses, + self._field0, + settings=self.settings, + initial_z=self.initial_z, + noise=self.noise, + noise_resolution=self.noise_resolution, + profiles=profiles, + configuration=configuration, + spatial_grid=self.spatial_grid, + medium=None + if self.medium is None + else dataclasses.replace(self.medium, material=mixture), + frame=self.frame, + wall=self.wall, + taper_coupling=self.taper_coupling, + materials=self.materials, + ) + updated._material_log = self._material_log.copy() + with _optmat.material_context(updated._material_log): + updated.noise, updated._noise_redraw = self._rebound_noise(updated.medium) + if self._recipe is not None: + self._warn_recipe_dropped("with_mixture") + updated.initial = self.initial + updated._input_identity = self._input_identity + updated._mixture_selection = self._mixture_selection + updated._reuse = _mixture_reuse(self, updated, old, mixture, original) + updated.translate() + return updated -def _unset(**keywords: object) -> ParameterPath: - """Return the path of the keywords among `keywords` left unset. + def metadata(self) -> RunMetadata: + """Build the record of this run, once, and return it. - - ``**keywords``: the keywords a refusal needs, by name; at least one is ``None``. - """ - return _path(*(name for name, value in keywords.items() if value is None)) + Cached because the record stamps the time it was created: two runs of the same + simulation are the same run, and their files must carry the same digest. + Returns + ------- + solprop.output.schema.RunMetadata + What produced the run, and the key a resume is checked against. -def _same_setting(old: object, new: object) -> bool: - """Return whether two values of one settings field are the same setting. + Examples + -------- + >>> from solprop.driver import Simulation + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + ... ) + >>> metadata = simulation.metadata() + >>> metadata.simulation_kind, metadata.spectral_convention + ('mode_averaged', 'optics') + >>> metadata.configuration_hash() == simulation.metadata().configuration_hash() + True + """ + # The backend's name is deliberately not here. A run continued on the other + # backend agrees with an uninterrupted one to 2.2-2.7e-16, which is smaller + # than the 4.1e-16 between two uninterrupted runs, so it is the same run and + # the digest keys on the problem and the state alone. What executed the run + # is recorded instead, outside the key, in `RunMetadata.backend`. + if self._metadata is None: + self._metadata = self._record(self._recorded_input()) + return self._metadata - - Returns: True for the same object, equal arrays, or values that compare equal; an - object compared by identity (``eq=False``) is the same only as itself, which is - what :func:`dataclasses.replace` keeps for every field it is not given. - """ - if old is new: - return True - if isinstance(old, np.ndarray) or isinstance(new, np.ndarray): - # The other side is an array-like setting (save_planes given as a list). - left = np.asarray(cast("np.ndarray", old)) - right = np.asarray(cast("np.ndarray", new)) - return left.shape == right.shape and bool(np.array_equal(left, right)) - try: - return bool(old == new) - except ValueError: - # A container of arrays has no single truth value; it is not provably the same. - return False - - -@dataclass(frozen=True) -class _AtomicContext: - """Hold what preparing a TDSE run's atomic resources needs from the run. - - Attributes - ---------- - handler : Output - The optical output, not yet opened. - state : ResumeState or None - What a resumed file holds. - metadata : RunMetadata - The run's record. - """ + def _recorded_input(self) -> object: + """Return the input as this run's record holds it, digesting a large array.""" + if self._input_identity is not None: + return StatedInput.of( + cast("np.ndarray", self.initial), self._input_identity + ) + # A gigabyte array is recorded by its digest (SEP 22, Q4). + return recorded_input(self.initial) - handler: Output - state: ResumeState | None - metadata: RunMetadata + def _recorded_keys(self) -> set[str]: + """Return the keys of this run's configuration record, digesting nothing. + What a derived run's caller keys are checked against, which a parent that is + only a placeholder for its inputs should not pay a digest of itself for. + """ + if self._record_keys is None: + record = self._metadata or self._record(None) + self._record_keys = set(record.configuration) + return self._record_keys -class Simulation(Displayed): - """A run that can be translated once and executed many times. + def _record(self, initial: object) -> RunMetadata: + """Build this run's record around what it says of its input.""" + configuration = dict(self.configuration) + configuration.update( + { + "responses": list(self.responses), + "initial": initial, + "profiles": dict(self.profiles), + "density": self.settings.density, + "shock": self.settings.shock, + "prefactor_index": self.settings.prefactor_index, + "absorber": self.settings.absorber, + "absorber_scheme": _scheme(self.settings), + "propagator_mode": self.settings.propagator_mode, + } + ) + if self.frame is not None: + configuration["frame"] = self.frame + elif _frame_follows_the_electronic_rule(self.responses, self.grid): + # A Raman thg flag kept beta_0 on an envelope grid before the frame + # followed the electronic responses alone (issue #77). Recording the + # co-moving frame here, and only here, refuses a resume of such an + # earlier run by name, which would otherwise continue under another + # frame with the same digest; every other run keeps its digest. + configuration["frame"] = Frame.comoving(self.grid) + # The operator settings of SEP 22 phase 2, recorded only where set, so + # every earlier run keeps its digest; a collar as its two numbers. + if self.settings.evanescent_rate is not None: + configuration["evanescent_rate"] = self.settings.evanescent_rate + if self.settings.spatial_absorber is not None: + configuration["spatial_absorber"] = self.settings.spatial_absorber + if self.settings.adiabatic_accuracy is not None: + configuration["adiabatic_accuracy"] = self.settings.adiabatic_accuracy + # A quiet collar changes what the stages compute (ADR-057), so the opt-in + # is part of the resume key, recorded only where taken; the rest of the + # check is the execution's, recorded in its record. + if _quiet_collar(self.settings): + configuration["quiet_collar"] = True + if self.spatial_grid is not None: + configuration.update( + spatial_grid=self.spatial_grid, medium=self.medium, frame=self.frame + ) + with _optmat.material_context(self._material_log): + return metadata_for( + self.translate(), + self._run_request(), + self.grid, + modes=self.modes, + configuration=configuration, + noise=self.noise, + modal=self._modal_identity(), + materials=self.material_manifest(), + quadrature=self.settings.quadrature, + frame=self.frame, + noise_resolution=self.noise_resolution, + defaults=self.settings.defaults_resolution(), + ) - Construct it from the physics objects and a :class:`SimulationSettings`, or through - :meth:`from_gnlse` for a GNLSE fibre. :meth:`translate` assembles the kernel problem - and caches it, so a second :meth:`run` -- into another handler, or resuming a - file -- reuses it. :attr:`settings` may be replaced until that first translation; - after it only its ``backend`` may change. + def material_manifest(self) -> MaterialManifest: + """Collect the run's material manifest: what it took from optmat, and how. - Parameters - ---------- - grid : solprop.grid.TimeGrid - The field or envelope grid. - modes : solprop.modes.protocol.Mode or sequence of Mode - The mode, or the ordered set of a multimode problem. - responses : collections.abc.Sequence - The declared nonlinear responses, as - :func:`solprop.translate.responses.compile_responses` takes them. - initial : solprop.pulses.Pulse or solprop.pulses.SpatialPulse or numpy.ndarray - The input: a :class:`solprop.pulses.Pulse` -- anything with a ``build(grid)`` - method -- or an already-built spectrum of shape ``(n_modes, n_pol, n_omega)`` in - the square root of watts and the internal convention. A pulse is the better - choice: it is a small specification, while a built array is recorded in the - metadata as itself. An array is copied here, so that changing the caller's - array afterwards changes neither the run nor its record: once in complex128, - which runs, and, for another dtype, once more as given, which is recorded; - ``Simulation.initial`` is a read-only view of the copy as given. A broadcast - view (:func:`numpy.broadcast_to`) copies only the block it repeats. On a - free-space or spatial route the translated problem holds the same complex128 - copy rather than another; on a modal route it holds the launch converted to - its internal amplitude beside the simulation's copy, two states in all. - settings : SimulationSettings - How to step it, what to record and which backend to use. - initial_z : float, optional - Absolute position of an array input, m, default zero. Every requested save - plane must follow it. An interior launch keeps the original profiles and - propagation frame; it requires a modal array input, no noise and no fixed - step schedule. - noise : solprop.noise.NoiseRealisation or None, optional - A shot-noise realisation to carry along the run (``docs/guide/media/noise.md``). - noise_resolution : solprop.output.schema.NoiseResolution or None, optional - How the noise model was chosen, as the constructors record it (SEP 37); ``None`` - records the realisation as asked for explicitly. - profiles : collections.abc.Mapping, optional - The z-profiles keyed by statistic name, for the ``density``, ``pressure`` and - ``core_radius`` statistics. Passed to the backend, which computes them. - configuration : collections.abc.Mapping, optional - Extra provenance to record beside what the simulation can see for itself, such - as the coefficients a mapping came from. - spatial_grid : RadialGrid, FreeGrid, PlanarGrid or None, optional - Transverse grid for spatial propagation; use an empty mode sequence (a - :class:`solprop.modes.slab.PlanarGrid` carries its own slab modes). - medium : solprop.spatial.SpatialMedium or None, optional - Bulk material, required with a spatial grid. - frame : solprop.linear.Frame or None, optional - Propagation frame. None uses the grid and responses' normal default. - general : solprop.translate.general.GeneralModalRoute or None, optional - The seam leaves of one general modal guide. Built by :meth:`from_waveguide` from - a ``provider=``; with it the run takes the general modal route, whose responses - are the guide's material regions' and whose declared `responses` must therefore - be empty. - guide : solprop.modes.guide.ModalGuide or None, optional - The general modal guide `general` was built from, kept so that the run's - material manifest finds what its regions hold (their laws and delayed - responses); it enters no translation. Set by :meth:`from_waveguide`. - wall : solprop.modes.capillary.CoherentWall or None, optional - The declaration of a coherent wall block. Built by :meth:`from_capillary` - from a ``wall=``; with it the mode set must be the contiguous ``HE_11`` to - ``HE_1N`` block -- or, with ``impedance="te_tm"``, one azimuthal order -- on a - constant or a varying guide, the cumulative operator carries the - mode-independent carrier alone and the modal part of the flow becomes - ``linear.coupled_flow``, which ``numpy_ref`` and ``native`` execute. - taper_coupling : bool, optional - Carry the **taper coupling** of a multimode capillary whose radius varies along - z: the connection ```` between the ``HE_1m`` channels, lossless - or not, in a coupled flow beside each channel's own propagation constant. - ``True``, the default; ``False`` is the explicit opt-out to the **adiabatic** - route, on which each channel follows its local mode and no power is exchanged. - No effect on a set the coupling does not apply to - (:func:`solprop.modes.capillary.taper_coupling_block`); ``False`` beside a - `wall` is refused, because the wall carries its connection. - materials : solprop.physdata.selection.MaterialSelection or None, optional - The material selection the run was built with, recorded in its material - manifest with every optmat-backed object the run carries. - The objects themselves are taken as they are: the ``from_*`` constructors - resolve the built-in materials under the selection. ``None`` records none. + Every optmat-backed object the run carries is found where it sits -- the + mixture, the modes' fills and claddings, a planar grid's slab modes, a guide's + material regions, a bulk medium -- with the selection the run was built with, + the policies in force and what was measured about them: the dilute-scaling error + over the run's whole band, collars included, the loss a transparent solid + dropped, and the bands outside a model's qualified intervals that the run's + range warnings named. :meth:`metadata` records it once; call it after + :meth:`translate` for a complete measurement. After :meth:`run` it also + carries the post-run diagnostic of each optmat ionisation rate + (``peak_fields``), which the file's record, written before the run, does not: + the run writes it beside the record instead, as the output's post-run + annotation :data:`~solprop.output.schema.PEAK_FIELDS_ANNOTATION`, outside the + configuration hash. - Raises - ------ - DriverError - If `initial` is neither an array nor something with a ``build`` method. + Returns + ------- + solprop.physdata.selection.MaterialManifest + The manifest; empty for a run that takes nothing from optmat. - Examples - -------- - >>> from solprop.driver import Simulation, SimulationSettings - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.nonlinear.gnlse import problem_from - >>> from solprop.pulses import SechPulse - >>> from solprop.stepper import PIController - >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1.2e-12) - >>> gnlse = problem_from([0.0, 0.0, -1.183e-26], omega_ref=grid.omega0, gamma=0.11, - ... shock=False) - >>> simulation = Simulation( - ... grid, gnlse.mode, [gnlse.kerr], - ... SechPulse(wavelength=835e-9, width=100e-15, peak_power=9.8), - ... settings=SimulationSettings( - ... controller=PIController(rtol=1e-6, atol=1e-16), - ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3, - ... density=1.0, shock=False, prefactor_index=1.0, - ... ), - ... ) - >>> handler = simulation.run() - >>> handler.n_saved, simulation.translate().shape - (2, (1, 1, 1024)) - """ + Examples + -------- + >>> from solprop.driver import Simulation + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, raman=True, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + ... ) + >>> [record.name for record in simulation.material_manifest().records] + ['kerr/SiO2', 'silica_raman/hollenbeck_2002', 'silica_bundle/default'] + """ + # The band edge of a dispersive law depends on whether the node field is a + # vector: the translation decides it on the modal and spatial routes, and a + # general modal guide's node field always has three components. + vector = self.general is not None and self.general.dispersive is not None + leaf = None + if any(isinstance(one, DispersiveKerrResponse) for one in self.responses): + problem = self.translate() + vector = problem.transform.n_pol * problem.shape[1] > 1 + leaf = problem.responses.dispersive_kerr + elif vector: + leaf = self.translate().responses.dispersive_kerr + with _optmat.material_context(self._material_log): + return material_manifest( + ( + self.configuration, + self.modes, + self.responses, + self.medium, + self.spatial_grid, + self.general, + self.guide, + self.wall, + ), + selection=self.materials, + omega=self.grid.omega, + log=self._material_log, + peak_fields=self._peak_fields, + carrier=self.grid.reference_wavelength, + active_band=(0.5 * self.grid.omega_min, self.grid.omega_max_window), + vector=vector, + leaf=leaf, + ) - # How a constructor that drew the noise without a seed draws it again from a given - # one: set per instance by `_redrawable` and carried to a run derived with its - # noise kept (`_rebound_noise`), read by `_reseed`. - _noise_redraw: Callable[[int | None], NoiseRealisation | None] | None = None - # The recipe the simulation was built from (SEP 41, S2), set by - # solprop.recipe.Recipe.build and dropped by every change it would not describe. - _recipe: RecipeSource | None = None + def _modal_identity(self) -> Mapping[str, JsonValue] | None: + """Return the modal identity of this run's record, or none at all. - def __init__( - self, - grid: TimeGrid, - modes: Mode | Sequence[Mode], - responses: Sequence[object], - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - initial_z: float = 0.0, - noise: NoiseRealisation | None = None, - noise_resolution: NoiseResolution | None = None, - profiles: Mapping[str, ZProfile] | None = None, - configuration: Mapping[str, object] | None = None, - spatial_grid: RadialGrid | FreeGrid | PlanarGrid | None = None, - medium: SpatialMedium | None = None, - frame: Frame | None = None, - general: GeneralModalRoute | None = None, - guide: ModalGuide | None = None, - wall: CoherentWall | None = None, - taper_coupling: bool = True, - materials: MaterialSelection | None = None, - ) -> None: - """Adopt the physics objects and the settings; translate nothing yet.""" - _check_selection_carrier(materials, grid.reference_wavelength) - self._base_grid = grid - declared = tuple(responses) - _check_electronic_policy(materials, declared) - grid, settings = _resolve_tdse_grid(grid, declared, settings) - self.grid = grid - self.materials = materials - # The run's material log: the one its constructor opened, or a new one, which - # its translation, record and run resume (`_one_material_context`). - self._material_log = _optmat.open_log() or MaterialLog() - self.general = general - self.guide = guide - self.wall = wall - self.taper_coupling = taper_coupling - self.modes: tuple[Mode, ...] = ( - tuple(modes) if isinstance(modes, Sequence) else (modes,) - ) - self.responses = declared - self.initial = initial - self.initial_z = initial_z - self._problem: PropagationProblem | None = None - # The request translation returned with the problem: every policy of a fresh - # run of it (SEP 47 section 5.2). - self._request: RunRequest | None = None - # The transverse edge's smallest band wavenumber by run length, fixed with the - # medium and grids: a planar run's costs a slab-mode evaluation per mode, which - # every run repeated (``_transverse_edge``). - self._edge_k_min: dict[float, float] = {} - self.settings = settings - if settings.controller is None: - msg = ( - "settings.controller is None: Simulation.from_radial and " - "Simulation.from_planar supply their interface's default tolerance, " - "but a Simulation built directly needs a PIController" - ) - raise DriverError(msg) - self.noise = noise - self.noise_resolution = ( - NoiseResolution( - None if noise is None else noise.model, - None if noise is None else noise.model, - ) - if noise_resolution is None - else noise_resolution - ) - self.profiles = dict(profiles or {}) - self.configuration = dict(configuration or {}) - if (spatial_grid is None) != (medium is None): - msg = "spatial_grid and medium must be supplied together" - raise DriverError(msg) - if spatial_grid is not None and self.modes: - msg = "spatial propagation requires an empty mode sequence" - raise DriverError(msg) - self.spatial_grid = spatial_grid - self.medium = medium - self.frame = frame - _check_initial_position(self, settings) - _check_tdse_execution(self, settings) - self._metadata: RunMetadata | None = None - self._mixture_selection: ResponseSelection | None = None - self._peak_fields: tuple[PeakFieldReport, ...] = () - self._reuse: TranslationReuse | None = None - self._input_identity: str | None = None - self._record_keys: set[str] | None = None - self._field0 = self._built_input(initial, grid, initial_z) - if isinstance(initial, np.ndarray): - # What was passed is what runs and what is recorded: a read-only view of - # the simulation's own copy, as the caller gave it (`_given_input`). - self.initial = _given_input(initial, self._field0) + Three routes declare one: a general modal guide, whose identity names the + provider, the tables and the basis; a capillary carrying a coherent wall block, + whose identity names the flow instead; and a multimode capillary taper carrying + the taper coupling without a wall, whose identity names that flow + (:func:`solprop.translate.coupled.taper_identity`). A run with none carries no + identity. - def _built_input( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - grid: TimeGrid, - initial_z: float, - ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return the input as the simulation holds it, building a pulse on its grids. + The wall block's validity ratios are re-derived here from the mode set rather + than carried out of the translation layer: they are a handful of Bessel zeros + over a wavenumber, the record must hold scalars and not arrays, and a second + evaluation of the same declared geometry cannot disagree with the first. It + is the **second** evaluation, though, so it says nothing the first did not: + ``translate()`` above has already built the same block and raised the same + grazing-incidence and material-range warnings, and a caller warned twice + about one geometry would reasonably look for two of them. They are silenced + here and nowhere else. - A spatial pulse is built on the spatial grid in the medium, in the simulation's - frame, which it sets to the comoving frame when none was given. + For a guide that varies along z the scan is over the **edges and the midpoints + of the flow's own sections**: the midpoints are where the frozen blocks were + built, and the edges are where a monotone profile is extreme, so the recorded + ratios are the worst geometry the run actually reaches rather than the + entrance's, and they are the geometry the admission check warns about. Scanning + the midpoints alone would record a radius the run merely passes through -- on a + 50 to 30 micrometre linear taper, 40 rather than 30 micrometres, a third + optimistic in every ratio (:func:`solprop.modes.capillary.worst_wall_limits`). - - ``initial_z``: the launch position, m. - - Returns: the spectrum, complex128, read-only and the simulation's own - (`_built`). + - Returns: the ``modal`` record, or ``None``. """ - if not isinstance(initial, SpatialPulse | SpatialPulseSum): - return _built(initial, grid) - if self.spatial_grid is None or self.medium is None: - msg = "a SpatialPulse requires spatial_grid and medium" - raise DriverError(msg) - if self.frame is None: - self.frame = Frame.comoving( - grid, thg=declares_harmonic_carrier(self.responses, grid) + if self.general is not None: + return modal_identity( + self.general, + self.grid, + channels=[mode.channel_label for mode in self.modes], + has_noise=self.noise is not None, + # A declared basis of a split block puts a coupled flow on the + # general route too, and a file that did not say so could resume + # against a different block in the same gauge. + flow=self.translate().linear.coupled_flow, ) - return _built_state( - initial, - initial.build( - grid, - self.spatial_grid, - medium=self.medium, - z=initial_z, - frame=self.frame, - ), + flow = self.translate().linear.coupled_flow + if flow is None: + return None + if self.wall is None: + # The taper coupling of a multimode capillary taper: + # the only coupled flow a mode-set route builds without a wall. + problem = self.translate() + return taper_identity( + flow, + channels=[mode.channel_label for mode in self.modes], + active=self.grid.active_mask, + scored=problem.grids.inband_mask, + n_components=problem.transform.n_pol, + has_noise=self.noise is not None, + ) + edges = np.asarray(flow.z_edges, dtype=float) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", GrazingIncidenceWarning) + warnings.simplefilter("ignore", MaterialRangeWarning) + limits = worst_wall_limits( + cast("Sequence[MarcatiliMode]", self.modes), + self.grid.omega, + positions=np.unique( + np.concatenate([edges, 0.5 * (edges[:-1] + edges[1:])]) + ).tolist(), + active=self.grid.active_mask, + scored=self.translate().grids.inband_mask, + validity_limit=self.wall.validity_limit, + impedance=self.wall.impedance, + ) + return wall_identity( + flow, + limits, + channels=[mode.channel_label for mode in self.modes], + impedance=self.wall.impedance, + scored=self.translate().grids.inband_mask, + n_components=self.translate().transform.n_pol, + has_noise=self.noise is not None, ) - @property - def settings(self) -> SimulationSettings: # numpydoc ignore=RT01 - """How the run steps, records and executes; fixed once translated. - - Assigning a new :class:`SimulationSettings` before the first :meth:`translate` - (or :meth:`run`) takes effect. After it the translated problem is cached, so an - assignment that changes anything the translation consumed is refused, because - it could no longer change the run; the ``backend`` and ``tdse_execution`` - resources, which are read when the run executes, may still change. + def _boundary_monitor(self) -> BoundaryMonitor | None: + """Return the boundary monitor of a fresh run, or ``None`` without a check.""" + check = self.settings.boundary_check + if check is None: + return None + boundary = self.settings.absorber + rate = ( + None + if boundary is None or not np.any(boundary.temporal_rate) + else boundary.temporal_rate + ) + space = self.spatial_grid + columns = ( + np.asarray(space.hankel.spectral_weights, dtype=np.float64) + if isinstance(space, RadialGrid) + else None + ) + monitor = BoundaryMonitor.build( + self.grid, + rate, + check, + columns=columns, + split=rate is not None and _scheme(self.settings) == "split_step", + ) + if isinstance(space, RadialGrid | FreeGrid | PlanarGrid) and ( + self.medium is not None + ): + monitor.transverse = self._transverse_edge(check, space, self.medium) + return monitor - The settings the run is, or will be, translated with. - """ - return self._settings + def _transverse_edge( + self, + check: BoundaryCheck, + space: RadialGrid | FreeGrid | PlanarGrid, + medium: SpatialMedium, + ) -> TransverseEdge: + """Return the transverse edge a free-space run's monitor reads (SEP 40 5.3).""" + problem = self.translate() + planes = self._run_request().observation.save_planes + length = float(np.max(planes)) - float(problem.initial.z) + k_min = self._edge_k_min.get(length) + if k_min is None: + dispersion = SpatialDispersion(medium, self.grid, Frame.comoving(self.grid)) + k, _ = band_wavenumbers(space, dispersion, max(length, 0.0)) + k_min = self._edge_k_min[length] = float(np.min(k)) + grid = self.grid + return TransverseEdge( + space, + transverse_of(problem.route), + check.edge_budget, + check.stop_factor, + length, + np.nonzero(grid.inband_mask & grid.active_mask)[0], + k_min, + ) - @settings.setter - def settings(self, settings: SimulationSettings) -> None: - """Replace settings; after translation, execution resources alone. + def run( + self, + output: Output | Path | str | None = None, + *, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, + resume: _ResumeChoice = False, + tdse_archive: TDSEArchive | None = None, + snapshots: SnapshotController | None = None, + stop: StopFlag | None = None, + ) -> Output: + """Execute the run and return the handler it was written to. Parameters ---------- - settings : SimulationSettings - The replacement. + output : solprop.output.schema.Output or pathlib.Path or str or None, optional + Where the planes go: a handler, a path to write an HDF5 file at, or ``None`` + for a :class:`solprop.output.memory.MemoryOutput`. The handler is closed + when the run ends, so a file handler is returned closed and is read back + with :func:`solprop.output.hdf5.open_file`; a memory handler stays readable. + A run with optmat ionisation rates then annotates it with their peak-field + report (:data:`~solprop.output.schema.PEAK_FIELDS_ANNOTATION`). + progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional + Where progress goes; ``None`` takes the process default: a + :class:`solprop.log.BarReporter` on a terminal or in a notebook, a + :class:`solprop.log.LogReporter` otherwise + (:func:`solprop.driver.run.run_problem`). + progress_period : float, optional + Seconds between the line reporter's periodic lines, s; ``0`` leaves only + the plane and closing lines. A bar has its own redraw period and a given + reporter its own business. + resume : bool or {'interpolated'}, optional + Continue the file at `output` instead of replacing it, when its record and + this run's have one configuration hash (``docs/guide/results/resuming.md``, + ADR-066); otherwise it is refused, naming the fields that differ. A finished + file is returned. Only a path can be resumed: the driver has to open the + file in append mode itself. Noise a constructor drew without a seed is drawn + again from the seed the file recorded, so re-running the same call continues + its file. A file whose last plane was interpolated inside a step is refused + unless this is ``"interpolated"``: it then continues afresh from the + interpolated field, and the position is recorded in the annotation + :data:`~solprop.output.schema.INTERPOLATED_RESUMES_ANNOTATION`. + tdse_archive : solprop.output.TDSEArchive or None, optional + Archive every raw TDSE provider evaluation of a run with a TDSE + response in this separate HDF5 file (SEP 20 section 5.8); ``None`` + archives nothing. It is opened and validated before any atomic work, a + resume appends a new session to its own run's archive, and the optical + output records the archive's run UUID and relative path. It changes + neither the propagation nor the call ledger. + snapshots : solprop.output.SnapshotController or None, optional + Publish immutable local checkpoints without rebuilding the backend or + atomic provider, each at the next accepted endpoint, after every + interpolated plane its step crossed. Requires HDF5 and ``fields='all'``. + Adams history cannot be explicitly disabled. A configured archive is + copied together with the optical file. + stop : object or None, optional + This run's own stop flag: any object with an ``is_set()`` method, such as + a :class:`threading.Event` (SEP 41, S17). It is polled before the first + step and before every trial step, so the run answers within one step or + trial; on a native ``Multistep()`` march, whose trials run in native calls + that cannot read it, before every call, so the run answers within 256 + attempts or 0.1 s and the attempt in flight, whichever comes first. When it + is set the run writes its state as for SIGTERM, tells the + reporter through ``interrupted``, and returns the handler: a requested + stop is not an error. The outcome's message says the caller's flag + stopped it, and ``resume=True`` continues the file. A front end sets it + from any thread on any platform, without signals; each run polls its own. + + Returns + ------- + solprop.output.schema.Output + The handler the run was written to, complete or, after `stop` was set, + holding the state where it stopped. Raises ------ + ValueError + If `resume` is not one of its three values. DriverError - If the simulation has already been translated and the replacement changes - a setting other than ``backend``, ``resume_history``, ``fields``, - ``field_planes``, ``tdse_execution`` or ``boundary_check``, naming them. - """ - if self._problem is not None: - changed = [ - one.name - for one in dataclasses.fields(SimulationSettings) - if one.name not in _EXECUTION_SETTINGS - and not one.name.startswith("_") - and not _same_setting( - getattr(self._settings, one.name), getattr(settings, one.name) - ) - ] - if changed: - msg = ( - f"this Simulation is already translated, so a new " - f"{', '.join(changed)} would be ignored; set the settings before " - "the first translate() or run(), or build a new Simulation (only " - "the backend, resume_history, fields, field_planes, the TDSE " - "execution resources and boundary_check may change after " - "translation)" - ) - raise DriverError(msg) - if self._recipe is not None: - dropped = [ - one.name - for one in dataclasses.fields(SimulationSettings) - if one.name not in _RECIPE_EXECUTION - and not one.name.startswith("_") - and not _same_setting( - getattr(self._settings, one.name), getattr(settings, one.name) - ) - ] - if dropped: - self._drop_recipe(f"its {', '.join(dropped)} changed") - if hasattr(self, "noise"): - _check_initial_position(self, settings) - _check_tdse_execution(self, settings) - if _quiet_collar(settings) != _quiet_collar(self._settings): - # The opt-in is part of the resume key: the record is built again. - self._metadata = None - self._settings = settings - - def _kind(self) -> str: - """Return what kind of run this is, for printing.""" - if self.spatial_grid is not None: - return type(self.spatial_grid).__name__ - count = len(self.modes) - if self.general is not None: - return f"{count} guide channel" + ("" if count == 1 else "s") - return f"{count} mode" if count == 1 else f"{count} modes" - - def __repr__(self) -> str: - """Return a one-line description: the grid, the geometry and the responses. - - Returns - ------- - str - For example ``Simulation(FieldGrid(...), 2 modes, responses=[Kerr, Plasma], - backend=auto)``. - """ - responses = ", ".join(type(response).__name__ for response in self.responses) - return line( - "Simulation", - [ - ("", repr(self.grid)), - ("", self._kind()), - ("responses", f"[{responses}]"), - ("backend", self.settings.backend), - ], - ) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - names = [type(response).__name__ for response in self.responses] - initial = ( - summarise(self.initial) - if isinstance(self.initial, np.ndarray) - else repr(self.initial) - ) - rows: list[tuple[str, object]] = [("grid", repr(self.grid))] - if self.spatial_grid is not None: - rows.append(("space", repr(self.spatial_grid))) - if self.general is not None: - rows.append(("guide", repr(self.general))) - rows.extend( - (f"mode {index}", repr(mode)) for index, mode in enumerate(self.modes) - ) - rows.extend( - [ - ( - "responses", - ", ".join(names) - or ("the guide's regions" if self.general is not None else "none"), - ), - ("input", initial), - ( - "noise", - ( - "none" - if self.noise_resolution.reason is None - else f"none ({self.noise_resolution.reason})" - ) - if self.noise is None - else f"{self.noise.model} model, seed {self.noise.seed}", - ), - ("settings", str(self.settings)), - ] - ) - return f"Simulation: {self._kind()}", rows - - def translate(self) -> PropagationProblem: - """Assemble the kernel problem, once, and return it. - - Everything expensive about a run that does not depend on the state happens here - (``docs/dev/architecture.md`` section 7): the cumulative linear operator, the - masks, the modal transform and the compiled responses. The result is cached, so - two runs of the same simulation translate once. - - Returns - ------- - solprop.translate.problem.PropagationProblem - Validated, versioned and ready for a backend. + If a resume is asked for and `output` is not a path, or the file's last + plane was interpolated and `resume` is not ``"interpolated"``; or + `tdse_archive` is given without a TDSE response or at the + path of the optical file. + TDSEArchiveError + If the archive's path holds another file, or a write to it fails; the + run then stops at its last optical checkpoint. + OutputError + If a file is there and its configuration hash differs; the message names + what changed. Examples -------- >>> from solprop.driver import Simulation >>> simulation = Simulation.from_gnlse( ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, ... ) - >>> problem = simulation.translate() - >>> problem.shape, simulation.translate() is problem - ((1, 1, 768), True) + >>> handler = simulation.run() + >>> handler.n_saved, handler["z"].tolist() + (3, [0.0, 0.005, 0.01]) """ - if self._problem is None: - settings = self.settings - if ( - self._reuse is None - and self.spatial_grid is None - and self.general is None - and self.wall is None - and self.taper_coupling - ): - _warn_adiabatic_taper(self.modes) - with _optmat.material_context(self._material_log): - self._problem, self._request = assemble( - self.grid, - self.modes if self.spatial_grid is None else None, - list(self.responses), - self._field0, - initial_z=self.initial_z, - # Simulation.__init__ refuses a missing controller. - controller=cast("PIController", settings.controller), - save_planes=settings.save_planes, - dz_max=settings.dz_max, - dz_init=settings.dz_init, - dz_min=settings.dz_min, - stretch=settings.stretch, - auto_init=settings.auto_init, - interval_cap=settings.interval_cap, - interpolate_planes=settings.interpolate_planes, - step_planes=settings.step_planes, - # The contract carries the Runge-Kutta march as its tableau and - # no integrator; an Adams method or the low-storage march as the - # integrator, beside an unused DOPRI5. - tableau=( - settings.integrator.tableau - if isinstance(settings.integrator, RungeKutta) - else DOPRI5 - ), - integrator=( - None - if isinstance(settings.integrator, RungeKutta) - else settings.integrator - ), - schedule=settings.schedule, - propagator_mode=settings.propagator_mode, - density=settings.density, - noise=self.noise, - absorber=settings.absorber, - absorber_scheme=_scheme(settings), - quadrature=settings.quadrature, - components=settings.components, - ionisation_field_range=( - settings.ionisation_field_range if self._reuse is None else None - ), - stats_spec=settings.stats, - shock=settings.shock, - prefactor_index=settings.prefactor_index, - polarisation_taper=settings.polarisation_taper, - spatial_grid=self.spatial_grid, - medium=self.medium, - frame=self.frame, - general=self.general, - wall=self.wall, - taper_coupling=self.taper_coupling, - reuse=self._reuse, - evanescent_rate=settings.evanescent_rate, - spatial_absorber=_transverse_specification( - settings.spatial_absorber, self.spatial_grid - ), - adiabatic_accuracy=settings.adiabatic_accuracy, - ) - return self._problem + return self._run( + output, + progress=progress, + progress_period=progress_period, + resume=resume, + tdse_archive=tdse_archive, + snapshots=snapshots, + stop=stop, + ) - def _run_request(self) -> RunRequest: - """Return the request translation returned with the problem, translating once. + def _run( # noqa: PLR0915 - run's controls, and the backend it may be handed + self, + output: Output | Path | str | None = None, + *, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, + resume: _ResumeChoice = False, + tdse_archive: TDSEArchive | None = None, + snapshots: SnapshotController | None = None, + stop: StopFlag | None = None, + kept: Backend | None = None, + ) -> Output: + """Execute the run, on a new backend or on one a caller keeps between runs. - Every policy of a fresh run of :meth:`translate`'s problem: the method, the - error control, the itinerary, the planes and the statistics (SEP 47 section - 5.2). A run adapts it to its start, retention, monitor and history - (:meth:`~solprop.translate.run_request.RunRequest.for_run`). + - ``progress_period``: as for :meth:`run`, s. + - ``kept``: a backend built for this problem but for its input, which is loaded + and left open for the caller; ``None`` constructs one and closes it. It serves + a fresh run only, with no TDSE response. """ - self.translate() - assert self._request is not None # set with the problem - return self._request - - def input_port(self, z: float | None = None) -> InputPort: - """Return what this simulation can receive at its entrance (SEP 31). - - The port is the receiving side of a transfer between simulations: its route, - temporal lattice and basis with their identities, the entrance plane and the - frame accumulated there, the native metric at the entrance, the reference - index of its entrance medium, the coupling models it admits and what they - need: an ordinary mode set's scalar profiles, or a general guide's E/H - reference at its nodes and its regions' contrast. A launch computed for it - (:func:`solprop.coupling.coupler.couple`) is accepted by :meth:`with_input`, - and a launch computed for any other port is refused. - - Parameters - ---------- - z : float or None, optional - An interior entrance in m, for a modal run continued from inside its own - path; None is the simulation's launch position. + _check_resume_choice(resume) + with _optmat.material_context(self._material_log): + if resume: + _adopt_recorded_seed(self, output) + problem = self.translate() + request = self._run_request() + _refuse_pending_check(problem) + metadata = self.metadata() + handler, state = _open_output(output, metadata, resume=resume) + if state is not None: + next_plane = _validate_resume( + problem, request, state, self.settings.tdse_execution + ) + if not _continues( + Path(cast("Path | str", output)), + request, + state, + next_plane, + resume, + ): + handler.open(state.metadata) + handler.close() + return handler + _warn_sampled_peak(self.settings.quadrature, request.observation.stats) + retention = field_retention( + request, self.settings.fields, self.settings.field_planes + ) + if tdse_descriptor(problem.route) is not None and not isinstance( + handler, CheckpointOutput + ): + raise DriverError( + "TDSE output must support terminal checkpoints and call ledgers" + ) + _check_tdse_archive(tdse_archive, problem, handler) + check_snapshot_request( + request, + handler, + snapshots, + self.settings.resume_history, + retention.fields, + ) + if ( + snapshots is not None + and tdse_descriptor(problem.route) is not None + and state is not None + and _recorded_archive_uuid(state) is not None + and tdse_archive is None + ): + raise DriverError( + "snapshots require the archive referenced by " + "the resumed optical file" + ) + backend: Backend | None = None + binding: TDSEBinding | None = None + archive: TDSEArchiveWriter | None = None + failure: BaseException | None = None + try: + if tdse_descriptor(problem.route) is not None: + binding, archive = _prepare_atomic( + self, + problem, + _AtomicContext(handler, state, metadata), + tdse_archive, + ) + backend = kept or select( + self.settings.backend, + problem, + request, + grid=self.grid, + modes=self.modes, + profiles=self.profiles, + tdse_binding=binding, + options=self.settings.execution_options(), + ) + restoration: dict[str, object] = {} + start = _start( + self, + backend, + problem, + state, + kept=kept is not None, + restoration=restoration, + ) + _restore_atomic(binding, start) + _consistency_gate(self, binding, problem, handler, metadata, backend) + run_problem( + problem, + backend, + handler, + start=start, + metadata=metadata, + stats_spec=request.observation.stats, + progress=progress, + progress_period=progress_period, + tdse_binding=binding, + resume_history=self.settings.resume_history, + fields=retention.fields, + field_planes=retention.field_planes, + snapshots=snapshots, + tdse_archive=archive, + boundary_monitor=self._boundary_monitor(), + stop=stop, + recipe=None if self._recipe is None else self._recipe.record(self), + restoration=restoration or None, + ) + except StopRequested as error: + failure = error + # A stop raised by another run's flag is that run's to catch. + if error.flag is not stop: + raise + except BaseException as error: + failure = error + raise + finally: + _close_run( + None if kept is not None else backend, + binding, + archive, + handler, + failure, + ) + self._peak_fields = _peak_field_reports(problem, handler, self.responses) + if ( + state is not None + and _interpolated(state) + and isinstance(handler, PlaneStore) + ): + # Allowed only by resume="interpolated"; the record says where. + earlier = handler.annotations.get(INTERPOLATED_RESUMES_ANNOTATION, []) + handler.annotate( + INTERPOLATED_RESUMES_ANNOTATION, + [*cast("list[JsonValue]", earlier), float(state.plane.z)], + ) + if self._peak_fields and isinstance(handler, PlaneStore): + # Learned only now the run has closed, so outside its record and its + # configuration hash: a post-run annotation (SEP 3, Q13). + handler.annotate( + PEAK_FIELDS_ANNOTATION, + [cast("JsonValue", report.to_dict()) for report in self._peak_fields], + ) + return handler + + def estimate(self) -> "RunEstimate": + """Estimate what the run would cost, without running it (SEP 41, S11). + + The grid, the persistent memory the constructed backend holds, the output's + size, the cost of one right-hand-side evaluation on the backend, timed after an + untimed warm-up that takes compilation and planning, and the integrator's calls + per step (:mod:`solprop.driver.estimate`). It constructs the backend, so a run + that doesn't fit in memory fails here as the run would. Returns ------- - solprop.coupling.InputPort - The port and the models it admits. - - Raises - ------ - DriverError - For a hybrid planar run, whose slab columns are not exchanged in this - release, or an interior entrance outside the run. + solprop.driver.estimate.RunEstimate + The estimate. """ - problem = self.translate() - metadata = self.metadata() - reference = cast("Mapping[str, object]", metadata.reference) - bases = cast("Mapping[str, object]", reference["basis"]) - route = cast("str", bases["route"]) - if route == "planar": - msg = ( - "a hybrid planar run's columns are slab amplitudes per y frequency; " - "planar planes are not exchanged in this release" - ) - raise DriverError(msg) - if z is not None and not ( - math.isfinite(z) and 0.0 <= z < float(np.max(self.settings.save_planes)) - ): - msg = f"an interior entrance lies inside the run, got z = {z!r} m" - raise DriverError(msg) - identity = cast("list[str]", bases["identity"])[0] - if route == "modal" and z is not None: - identity = modal_basis_identity(self.modes, self.grid, z) - labels = tuple(mode.channel_label.display_label for mode in self.modes) - space = ( - self.spatial_grid - if isinstance(self.spatial_grid, RadialGrid | FreeGrid) - else None - ) - basis = PlaneBasis( - cast("Literal['modal', 'general_modal', 'radial', 'cartesian']", route), - identity, - labels, - space, - ) - return fill_port( - problem, - self.grid, - route=basis.kind, - basis=basis, - frame=cast("dict[str, object] | None", reference["frame"]), - medium=self.medium, - tables=None if self.general is None else self.general.tables, - z=z, - modes=tuple(self.modes) if route == "modal" else (), - guide=self.guide if isinstance(self.guide, ModalGuide) else None, - ) + from solprop.driver.estimate import estimate # noqa: PLC0415 - imports this - def with_input( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray | CouplingLaunch, - *, - noise: NoiseRealisation | Literal["keep"] | None = "keep", - initial_z: float | None = None, - configuration: Mapping[str, object] | None = None, - input_identity: str | None = None, - ) -> "Simulation": - """Replace the input while sharing the translated propagation problem. + return estimate(self) - Only what changed is validated (SEP 22 section 5.2): the replaced input's - shape, dtype and one finiteness pass, and the replaced noise's, with every - other leaf of the translated problem shared by identity rather than scanned - again. A new `initial_z`, which changes the save planes, also keeps the - stepper's checks: the launch before the first plane, and a replayed schedule - containing every plane. An array input above - :data:`~solprop.output.schema.INLINE_ARRAY_LIMIT` elements is recorded by its - digest (:class:`~solprop.output.schema.ArrayDigest`), not inline, unless its - identity is stated (`input_identity`). + def calibrate(self, budget: float = 60.0) -> "RefinementReport": + """Refine the native backend's settings for this simulation (SEP 46 stage 2). + + Fresh native workspaces of this simulation's translated problem are built in + this process under candidate settings and timed by trial steps along its + length; the settings that win, stay accurate and validate together are stored + under its execution descriptor, and every later run or scan point of the same + execution applies them. The simulation itself is not changed. Running + ``solprop calibrate`` first gives the machine profile this refines + (:mod:`solprop.calibration`). Parameters ---------- - initial : Pulse, SpatialPulse, numpy.ndarray or solprop.coupling.CouplingLaunch - Pulse or internal-convention spectrum on the existing grid. The built - shape must match the original input, including modes and polarisation. - Modal amplitudes are in sqrt(W); spatial units follow the spatial grid. - An array is copied when the derived simulation is built, as by the - constructor. - A :class:`~solprop.coupling.port.CouplingLaunch` (SEP 31) must be bound to - this simulation's :meth:`input_port` -- its route, lattice, basis, - entrance and metric -- or it is refused whatever its shape; its - entrance sets the launch position, and its provenance and inherited - reference origin are recorded as ``configuration["coupling"]``. - noise : NoiseRealisation, {'keep'} or None, optional - Keep the existing noise, replace it explicitly, or disable it with None. - Noise spectra must match the existing grid and input shape. A launch - carries the realised field of its source, so for a launch ``'keep'`` - means no noise: no second floor is drawn unless a realisation is given. - Kept noise that was drawn without a seed can still be drawn again from - the seed a resume's file or a scan point supplies. - initial_z : float or None, optional - Absolute launch position in metres. None retains this simulation's - launch. An explicit position keeps only existing save planes beyond - it, preserving all absolute profiles, propagation tables and the frame. - configuration : collections.abc.Mapping or None, optional - Caller keys merged into the derived run's configuration record, and so - into its configuration hash: the delay of a scan, the label of an input. - A key the run already records is refused. - input_identity : str or None, optional - What an array input is, stated by the caller who already knows: the record - then holds this string, the dtype and the shape - (:class:`~solprop.output.schema.StatedInput`) in place of the array's own - record, and its bytes are not digested. **It is not checked**: two - different arrays stated alike have one configuration hash, so a resume or - a scan's cache accepts one for the other. ``None``, the default, records - the array as itself or by its digest. Refused for anything but an array. + budget : float, optional + The deadline, in seconds. Returns ------- - Simulation - Translated simulation sharing all propagation tables. Metadata is - rebuilt for the replacement input and noise. Existing ionisation - table ranges remain in force, and so does the reference field of an - optmat rate's constant energy per event, which is the original - input's peak; use a fresh translation to change them. - - Raises - ------ - DriverError - If the new field or noise shape differs or any sample is not finite, a - `configuration` key is one the run records itself, a launch is bound - to another port or given beside an `initial_z`, or `input_identity` is - empty or names anything but an array input. - - Warns - ----- - MaterialRangeWarning - Once per run, when the new input's peak exceeds an optmat rate's maximum - field, or the input is not linearly polarised at a Keldysh parameter - above one there (SEP 3, D12). + solprop.calibration.refine.RefinementReport + What was kept and whether it was stored. """ - if isinstance(noise, str) and noise != "keep": - raise DriverError("noise must be keep, None, or a NoiseRealisation") - _check_input_identity(input_identity, initial) - coupling = None - if isinstance(initial, CouplingLaunch): - launch = initial - initial, initial_z, noise = self._launched(launch, initial_z, noise) - coupling = launch.record() - original = self.translate() - launch_z = self.initial_z if initial_z is None else initial_z - settings = self.settings - if initial_z is not None: - remaining = np.asarray(settings.save_planes) - remaining = remaining[remaining > launch_z] - if not np.isfinite(launch_z) or launch_z < 0 or remaining.size == 0: - raise DriverError( - "initial_z must be finite, nonnegative and before the end" - ) - settings = dataclasses.replace(settings, save_planes=remaining) - replacement_noise = self.noise if isinstance(noise, str) else noise - resolution = ( - self.noise_resolution - if isinstance(noise, str) or noise is self.noise - else NoiseResolution( - None if noise is None else noise.model, - None if noise is None else noise.model, - ) - ) - if ( - isinstance(noise, str) - and launch_z > 0 - and self.noise_resolution.requested == "default" - and self.noise is not None - ): - # SEP 37: a default draw is not added at an interior launch, whose given - # spectrum may already carry the realisation of an earlier run. - replacement_noise = None - resolution = NoiseResolution("default", None, INTERIOR_LAUNCH_NOISE) - # An array is refused before the derived simulation copies it. - self._check_replaced_array( - initial, None if isinstance(noise, str) else replacement_noise - ) - updated = Simulation( - self._base_grid, - self.modes, - self.responses, - initial, - settings=settings, - initial_z=launch_z, - noise=replacement_noise, - noise_resolution=resolution, - profiles=self.profiles, - spatial_grid=self.spatial_grid, - medium=self.medium, - frame=self.frame, - general=self.general, - guide=self.guide, - wall=self.wall, - taper_coupling=self.taper_coupling, - materials=self.materials, - ) - # Recorded from what the derived run holds, not from the caller's input. - updated.configuration = self._derived_configuration( - updated.initial, configuration, coupling - ) - updated._input_identity = input_identity - # A derived run starts from what its parent's run has warned and recorded, - # and keeps its own from here: its siblings never see each other's. - updated._material_log = self._material_log.copy() - if isinstance(noise, str) and replacement_noise is not None: - updated._noise_redraw = self._noise_redraw - if self._recipe is not None: - self._warn_recipe_dropped("with_input") - if not isinstance(initial, np.ndarray): - self._check_replaced_input( - updated._field0, None if isinstance(noise, str) else replacement_noise - ) - record = dataclasses.replace( - original.initial, - z=launch_z, - field0=_replaced_launch(original, updated._field0, launch_z), - ) - updated._problem = _with_noise( - dataclasses.replace(original, initial=record), replacement_noise - ) - request = self._run_request() - updated._request = request.with_stepper( - dataclasses.replace(request.stepper, save_planes=settings.save_planes) - ) - route = updated._problem.route - if isinstance(route, SpatialRoute): - updated._problem = dataclasses.replace( - updated._problem, - route=dataclasses.replace( - route, - transverse=_rechecked( - self, - original, - updated._problem, - updated._request.observation.save_planes, - ), - ), - ) - if initial_z is not None: - # A new launch changes the save planes: the launch must precede them, and - # a replayed schedule contain them (SEP 22 section 5.2). - updated._problem.validate_launch(updated._request) - updated._mixture_selection = self._mixture_selection - with _optmat.material_context(updated._material_log): - _check_rate_inputs(updated, updated.initial) - return updated - - def _launched( - self, - launch: CouplingLaunch, - initial_z: float | None, - noise: NoiseRealisation | Literal["keep"] | None, - ) -> tuple[np.ndarray, float | None, NoiseRealisation | None]: - """Return what a coupling launch means for :meth:`with_input`, or refuse it. + from solprop.calibration.refine import refine # noqa: PLC0415 - imports native - - ``noise``: the noise asked for: a launch carries its source's realised field, - so ``'keep'`` draws no second floor. - - Returns: the launch's spectrum, its interior position (``None`` at this run's - own entrance) and its noise. - """ - if initial_z is not None: - msg = "a launch carries its own entrance: give no initial_z beside it" - raise DriverError(msg) - entrance = launch.port.geometry.z - interior = None if entrance == self.initial_z else entrance - refusal = self.input_port(interior).accepts(launch.port) - if refusal is not None: - msg = f"this launch was computed for another input port: {refusal}" - raise DriverError(msg) - return launch.internal(), interior, None if isinstance(noise, str) else noise + return refine(self, budget) - def _derived_configuration( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - configuration: Mapping[str, object] | None, - coupling: Mapping[str, object] | None = None, - ) -> dict[str, object]: - """Return a derived run's extra configuration: this run's, the caller's keys. + def output_bytes(self) -> int: + """Return the size of the run's output, without constructing a backend. - - ``coupling``: the provenance of a coupling launch, recorded as ``coupling``. - """ - recorded = dict(self.configuration) - # Nor does the constructor's incident-beam account describe it, nor a - # coupling that launched this run. - recorded.pop("launch", None) - recorded.pop("coupling", None) - # Nor does a modal launch describe a replacement input, which is placed as - # given. - recorded.pop("modal_launch", None) - # A constructor's pulse recipe must never describe a replacement spectrum. - # Arrays carry their exact input in metadata, without an inferred recipe. - if "pulse" in recorded: - recorded.pop("pulse") - if not isinstance(initial, np.ndarray): - recorded["pulse"] = initial - if configuration: - clash = sorted(set(configuration) & ({"coupling"} | self._recorded_keys())) - if clash: - msg = ( - f"configuration keys {clash} are recorded by the run itself; " - "name the caller's keys differently" - ) - raise DriverError(msg) - recorded.update(configuration) - if coupling is not None: - recorded["coupling"] = dict(coupling) - return recorded + The cheap part of :meth:`estimate`, for asking as the setup changes: it times + nothing and allocates no run state. - def _check_replaced_array( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - noise: NoiseRealisation | None, - ) -> None: - """Validate a replaced array input before it is copied; a pulse is built first. + Returns + ------- + int + Bytes, as an in-memory store holds them (an HDF5 file adds little). - - ``noise``: a replacement realisation, or ``None`` when the noise is kept or - removed. + Examples + -------- + >>> from solprop.recipe import Recipe + >>> simulation = Recipe.example("soliton").build() + >>> simulation.output_bytes() == simulation.estimate().output_bytes + True """ - if isinstance(initial, np.ndarray): - self._check_replaced_input(initial, noise) + from solprop.driver.estimate import output_bytes # noqa: PLC0415 - imports this - def _check_replaced_input( - self, - field0: Shaped[np.ndarray, "*shape"], - noise: NoiseRealisation | None, - ) -> None: - """Validate a replaced input alone: its shape and one finiteness pass. + return output_bytes(self) - Every other leaf of the derived problem is this one's, shared by identity and - validated when it was translated (SEP 22 section 5.2); the dtype is fixed by - the build. + def _reseed(self, seed: int) -> bool: + """Draw a noise realisation that was drawn without a seed again, from `seed`. - - ``noise``: a replacement realisation, or ``None`` when the noise is kept or - removed. + A constructor given ``seed=None`` draws operating-system entropy, so building + the same call twice gives two realisations and two configuration hashes. A + resume (:meth:`run`) and a scan point (:func:`~solprop.driver.scan.run_scan`) + pass the seed that makes the run the one they continue or reproduce. A + realisation the caller seeded or supplied is never redrawn. """ - if field0.shape != self._field0.shape: - raise DriverError("with_input requires the existing mode and grid shape") - if not _all_finite(field0): - raise DriverError("with_input requires a finite initial field") - if noise is None: - return - if noise.spectrum.shape != self._field0.shape: - raise DriverError("replacement noise must match the input field shape") - if not _all_finite(noise.spectrum): - raise DriverError("with_input requires a finite noise realisation") - - def runner(self) -> "SimulationRunner": - """Return a runner that keeps this translation and one backend for many inputs. + if self._noise_redraw is None or self.noise is None or self.noise.seed == seed: + return False + self.noise = self._noise_redraw(seed) + if self._problem is not None: + self._problem = _with_noise(self._problem, self.noise) + self._metadata = None + return True - Translates (or reuses the cached translation) and constructs the backend once; - each :meth:`SimulationRunner.run` then runs one input on it (SEP 22 section - 5.2). Close it, or use it in a ``with`` block. + def _reseeded(self, seed: int) -> "Simulation": + """Return this run with unseeded noise drawn from `seed`, leaving it as it is. - Returns - ------- - SimulationRunner - The open runner. + A scan's builder may return one object for every point, and a batch keeps each + member by reference, so a point's realisation must not land on the object the + other points share. Translated first, so that they share its translation. - Raises - ------ - DriverError - If the run has a TDSE response. + - Returns: a copy carrying the new realisation and sharing the translation's + other leaves, with its own material log as any derived run has; or this run, + when there is nothing to draw again (:meth:`_reseed`). """ - return SimulationRunner(self) + if self._noise_redraw is None or self.noise is None or self.noise.seed == seed: + return self + self.translate() + seeded = copy.copy(self) + seeded._material_log = self._material_log.copy() + seeded._reseed(seed) + return seeded - def _drop_recipe(self, reason: str) -> None: - """Forget the recipe, which no longer describes this simulation, and warn.""" - self._recipe = None - msg = ( - f"this Simulation was built from a recipe, but {reason}, which the recipe " - "does not describe: its output will record no recipe, and resuming it " - "needs this script" - ) - warnings.warn(msg, SolpropWarning, stacklevel=3) + def _rebound_noise( + self, medium: SpatialMedium | None + ) -> tuple[ + NoiseRealisation | None, + Callable[[int | None], NoiseRealisation | None] | None, + ]: + """Return this run's noise and its redraw for the same run in a new medium. - def _warn_recipe_dropped(self, mutator: str) -> None: - """Warn that a derived simulation does not carry this one's recipe.""" - msg = ( - f"{mutator} derives a simulation the recipe this one was built from does " - "not describe: its output will record no recipe, and resuming it needs " - "the script" - ) - warnings.warn(msg, SolpropWarning, stacklevel=3) + A modal draw depends only on the grid, which the updated run shares. A spatial + draw is measured by the medium's flux, so it is rebuilt there, and the + realisation drawn again from its own seed: what a run carries must be what its + redraw gives, or a resume drawing from the recorded seed would disagree with + its own file. A realisation seeded or supplied by the caller stays as it is. - def with_mixture(self, mixture: Mixture) -> "Simulation": - """Update material paths while preserving the run's geometry and input. + - ``medium``: the updated run's medium; ``None`` on a modal route. + """ + noise, redraw = self.noise, self._noise_redraw + if noise is None or redraw is None or medium is None: + return noise, redraw + redraw = _spatial_draw(noise.model, self, medium) + return redraw(noise.seed), redraw - Rate tables and unchanged undamped Raman kernels are shared by identity. - Expansion terms are reused only for the identical sampled function with - covered parameter ranges; changing fill fractions invalidates a pressure - expansion. Every replacement weight and density or decay table is rebuilt. + @classmethod + @_one_material_context + def from_radial( + cls, + grid: TimeGrid, + space: RadialGrid, + medium: SpatialMedium, + responses: Sequence[object], + initial: SpatialPulse | SpatialPulseSum | np.ndarray, + *, + settings: SimulationSettings, + frame: Frame | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + materials: MaterialSelection | None = None, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + ) -> "Simulation": + """Build a radial propagation using physical fields and the scalar QDHT. Parameters ---------- - mixture : Mixture - New paths and populations with the same ordered labels and species data. - Public capillary response selections are retained; changed temperature - or Raman component options rebuild the affected molecular models. + grid : solprop.grid.TimeGrid + Field or envelope time grid, in SI units. + space : solprop.grid.RadialGrid + Radial nodes, outer radius and spatial collar. + medium : solprop.spatial.SpatialMedium + Homogeneous transverse material; gas density may vary along z. + responses : collections.abc.Sequence + Existing Kerr, plasma or explicitly declared Raman response objects. + initial : SpatialPulse, SpatialPulseSum or numpy.ndarray + Beam specification or internal Hankel spectrum in V m, with shape + ``(n_r, n_pol, n_omega)``. + settings : SimulationSettings + Step tolerances, positive save positions in m, statistics and backend. + Absolute tolerance has the input spectrum's units, V m. + frame : solprop.linear.Frame or None, optional + Moving frame. None uses the declared responses' THG choice. + noise : {'default', None, 'input', 'modified'}, optional + One photon per transverse column, polarisation and active bin. + ``"default"``, the default, resolves to ``"input"`` wherever the run can + carry noise, and records the resolution (SEP 37). + seed : int or None, optional + Random generator seed; None draws and records system entropy. + materials : solprop.physdata.selection.MaterialSelection or None, optional + Take the medium's gas from optmat under this selection: a + :class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the + one-population :class:`~solprop.physdata.mixtures.Mixture` of that gas + under the selection at the same pressure and temperature (a supplied + susceptibility is kept). A mixture, glass or constant index is taken as + it is. An optmat rate's energy per event is bound to the input's peak + field (SEP 3, Q14); every response, a + :class:`~solprop.nonlinear.responses.KerrResponse` included, is the + caller's (``gamma3("Ar", wavelength=...)`` is optmat's coefficient at a + carrier). The run records its material manifest. + ``None`` (the default) is ``MaterialSelection()``. + absorber : solprop.boundaries.Absorber, False or None, optional + Overrides `settings`' absorber, which must then declare none. ``False`` + switches the temporal and spectral collars off and keeps the hard band + mask; the transverse collars keep their own settings (SEP 21 section 5). + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + Overrides `settings`' temporal scheme, which must then be the default. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges (SEP 21), when `settings` + name the default check: the default warns, then stops, when light above + the shot-noise floor reaches them; ``None`` watches nothing. Refused + beside settings that name another check. + + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional + The transverse boundary (SEP 40), when `settings` declare none: ``None`` + keeps the settings' own, by default the safety ramp alone; a + :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in + the grid's room outside its requested size; ``False`` carries no ramp. Returns ------- Simulation - A translated simulation sharing the grid, input field, noise realisation, - numerical settings and transverse geometry with this one. Noise drawn - without a seed can still be drawn again from the seed a resume's file or - a scan point supplies; on a spatial route, where the draw is measured by - the medium's flux, it is drawn again now from the same seed on the new - medium. A realisation the caller seeded or supplied is shared unchanged. + Cached translation and the ordinary resumable execution interface. Raises ------ DriverError - If there is no mixture declaration, or labels, species or unsupported - mode geometries differ. + If a noise seed is given without noise or the input shape is invalid. """ - old = _simulation_mixture(self) - if old.labels != mixture.labels: - raise DriverError( - f"with_mixture requires the same ordered labels: {old.labels}, " - f"got {mixture.labels}" - ) - for before, after in zip(old.components, mixture.components, strict=True): - if serialise(before.species) != serialise(after.species): - raise DriverError( - f"with_mixture cannot change species data for {before.label!r}" - ) - original = self.translate() - declared = ( - self.responses - if self._mixture_selection is None - else self._mixture_selection.build(mixture) - ) - channels = { - channel.label: channel for channel in original.responses.population_channels - } - responses = tuple( - dataclasses.replace( - response, - rate=channels[_population_label(response, mixture)].rate, - preionisation=mixture[ - _population_label(response, mixture) - ].preionisation, - ) - if isinstance(response, PlasmaResponse | EnvelopePlasmaResponse) - else response - for response in declared - ) - _refuse_idle_preionisation(mixture, responses) - modes = tuple(_rebind_mode(mode, mixture) for mode in self.modes) - _warn_boreless_gradient(mixture, _flow_radius(self.modes, None)) - profiles = dict(self.profiles) - profiles.pop("pressure", None) - profiles.update(mixture_profiles(mixture, pressure="pressure" in self.profiles)) - configuration = dict(self.configuration) - configuration["medium"] = mixture - if "capillary" in configuration: - configuration["capillary"] = { - **cast("Mapping[str, object]", self.configuration["capillary"]), - "temperature": mixture.temperature, - } - if "waveguide" in configuration: - configuration["waveguide"] = { - **cast("Mapping[str, object]", self.configuration["waveguide"]), - "modes": [ModeRecord.from_mode(mode) for mode in modes], - "temperature": mixture.temperature, - } - updated = Simulation( - self._base_grid, - modes, + materials = MaterialSelection() if materials is None else materials + return _from_spatial( + cls, + grid, + space, + _selected_medium(medium, materials), responses, - self._field0, - settings=self.settings, - initial_z=self.initial_z, - noise=self.noise, - noise_resolution=self.noise_resolution, - profiles=profiles, - configuration=configuration, - spatial_grid=self.spatial_grid, - medium=None - if self.medium is None - else dataclasses.replace(self.medium, material=mixture), - frame=self.frame, - wall=self.wall, - taper_coupling=self.taper_coupling, - materials=self.materials, + initial, + settings=_bounded( + _watched( + _with_boundary(settings, grid, absorber, absorber_scheme), + boundary_check, + ), + spatial_absorber, + ), + frame=frame, + noise=noise, + seed=seed, + materials=materials, ) - updated._material_log = self._material_log.copy() - with _optmat.material_context(updated._material_log): - updated.noise, updated._noise_redraw = self._rebound_noise(updated.medium) - if self._recipe is not None: - self._warn_recipe_dropped("with_mixture") - updated.initial = self.initial - updated._input_identity = self._input_identity - updated._mixture_selection = self._mixture_selection - updated._reuse = _mixture_reuse(self, updated, old, mixture, original) - updated.translate() - return updated - def metadata(self) -> RunMetadata: - """Build the record of this run, once, and return it. + @classmethod + @_one_material_context + def from_free( + cls, + grid: TimeGrid, + space: FreeGrid, + medium: SpatialMedium, + responses: Sequence[object], + initial: SpatialPulse | SpatialPulseSum | np.ndarray, + *, + settings: SimulationSettings, + frame: Frame | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + materials: MaterialSelection | None = None, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + ) -> "Simulation": + """Build a Cartesian propagation with constant media and transverse FFTs. - Cached because the record stamps the time it was created: two runs of the same - simulation are the same run, and their files must carry the same digest. + A :class:`solprop.grid.LineGrid` runs the same transform with one y column: + the beam is uniform in y, the run records ``transverse_measure = + "per_unit_width"``, and its energies and powers are per metre of y. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + Field or envelope time grid, in SI units. + space : solprop.grid.FreeGrid + Cartesian axes, area measure and spatial collar, or a + :class:`solprop.grid.LineGrid`. + medium : solprop.spatial.SpatialMedium + Constant bulk material; varying profiles are refused. + responses : collections.abc.Sequence + Existing Kerr, plasma or explicitly declared Raman response objects. + initial : SpatialPulse, SpatialPulseSum or numpy.ndarray + Beam specification or internal Cartesian DFT spectrum in V/m, with shape + ``(n_x*n_y, n_pol, n_omega)``. + settings : SimulationSettings + Step tolerances, positive save positions in m, statistics and backend. + Absolute tolerance has the input spectrum's units, V/m. + frame : solprop.linear.Frame or None, optional + Moving frame. None uses the declared responses' THG choice. + noise : {'default', None, 'input', 'modified'}, optional + One photon per transverse column, polarisation and active bin. + ``"default"``, the default, resolves to no noise on a Cartesian or line + grid and records why (:data:`~solprop.driver.defaults.FREE_NOISE`, SEP + 37); pass ``"input"`` or ``"modified"`` for a noise model. + seed : int or None, optional + Random generator seed; None draws and records system entropy. + materials : solprop.physdata.selection.MaterialSelection or None, optional + Take the medium's gas from optmat under this selection: a + :class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the + one-population :class:`~solprop.physdata.mixtures.Mixture` of that gas + under the selection at the same pressure and temperature (a supplied + susceptibility is kept). A mixture, glass or constant index is taken as + it is. An optmat rate's energy per event is bound to the input's peak + field (SEP 3, Q14); every response, a + :class:`~solprop.nonlinear.responses.KerrResponse` included, is the + caller's (``gamma3("Ar", wavelength=...)`` is optmat's coefficient at a + carrier). The run records its material manifest. + ``None`` (the default) is ``MaterialSelection()``. + absorber : solprop.boundaries.Absorber, False or None, optional + Overrides `settings`' absorber, which must then declare none. ``False`` + switches the temporal and spectral collars off and keeps the hard band + mask; the transverse collars keep their own settings (SEP 21 section 5). + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + Overrides `settings`' temporal scheme, which must then be the default. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges (SEP 21), when `settings` + name the default check: the default warns, then stops, when light above + the shot-noise floor reaches them; ``None`` watches nothing. Refused + beside settings that name another check. + + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional + The transverse boundary (SEP 40), when `settings` declare none: ``None`` + keeps the settings' own, by default the safety ramp alone; a + :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in + the grid's room outside its requested size; ``False`` carries no ramp. Returns ------- - solprop.output.schema.RunMetadata - What produced the run, and the key a resume is checked against. + Simulation + Cached translation and the ordinary resumable execution interface. - Examples - -------- - >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, - ... ) - >>> metadata = simulation.metadata() - >>> metadata.simulation_kind, metadata.spectral_convention - ('mode_averaged', 'optics') - >>> metadata.configuration_hash() == simulation.metadata().configuration_hash() - True + Raises + ------ + DriverError + If a noise seed is given without noise or the input shape is invalid. """ - # The backend's name is deliberately not here. A run continued on the other - # backend agrees with an uninterrupted one to 2.2-2.7e-16, which is smaller - # than the 4.1e-16 between two uninterrupted runs, so it is the same run and - # the digest keys on the problem and the state alone. What executed the run - # is recorded instead, outside the key, in `RunMetadata.backend`. - if self._metadata is None: - self._metadata = self._record(self._recorded_input()) - return self._metadata - - def _recorded_input(self) -> object: - """Return the input as this run's record holds it, digesting a large array.""" - if self._input_identity is not None: - return StatedInput.of( - cast("np.ndarray", self.initial), self._input_identity + materials = MaterialSelection() if materials is None else materials + medium = _selected_medium(medium, materials) + if not medium.density.is_constant: + msg = ( + "Cartesian free propagation requires constant media; " + "varying density profiles are unsupported" ) - # A gigabyte array is recorded by its digest (SEP 22, Q4). - return recorded_input(self.initial) - - def _recorded_keys(self) -> set[str]: - """Return the keys of this run's configuration record, digesting nothing. - - What a derived run's caller keys are checked against, which a parent that is - only a placeholder for its inputs should not pay a digest of itself for. - """ - if self._record_keys is None: - record = self._metadata or self._record(None) - self._record_keys = set(record.configuration) - return self._record_keys - - def _record(self, initial: object) -> RunMetadata: - """Build this run's record around what it says of its input.""" - configuration = dict(self.configuration) - configuration.update( - { - "responses": list(self.responses), - "initial": initial, - "profiles": dict(self.profiles), - "density": self.settings.density, - "shock": self.settings.shock, - "prefactor_index": self.settings.prefactor_index, - "absorber": self.settings.absorber, - "absorber_scheme": _scheme(self.settings), - "propagator_mode": self.settings.propagator_mode, - } + raise DriverError(msg) + simulation = _from_spatial( + cls, + grid, + space, + medium, + responses, + initial, + settings=_bounded( + _watched( + _with_boundary(settings, grid, absorber, absorber_scheme), + boundary_check, + ), + spatial_absorber, + ), + frame=frame, + noise=noise, + seed=seed, + materials=materials, ) - if self.frame is not None: - configuration["frame"] = self.frame - elif _frame_follows_the_electronic_rule(self.responses, self.grid): - # A Raman thg flag kept beta_0 on an envelope grid before the frame - # followed the electronic responses alone (issue #77). Recording the - # co-moving frame here, and only here, refuses a resume of such an - # earlier run by name, which would otherwise continue under another - # frame with the same digest; every other run keeps its digest. - configuration["frame"] = Frame.comoving(self.grid) - # The operator settings of SEP 22 phase 2, recorded only where set, so - # every earlier run keeps its digest; a collar as its two numbers. - if self.settings.evanescent_rate is not None: - configuration["evanescent_rate"] = self.settings.evanescent_rate - if self.settings.spatial_absorber is not None: - configuration["spatial_absorber"] = self.settings.spatial_absorber - if self.settings.adiabatic_accuracy is not None: - configuration["adiabatic_accuracy"] = self.settings.adiabatic_accuracy - # A quiet collar changes what the stages compute (ADR-057), so the opt-in - # is part of the resume key, recorded only where taken; the rest of the - # check is the execution's, recorded in its record. - if _quiet_collar(self.settings): - configuration["quiet_collar"] = True - if self.spatial_grid is not None: - configuration.update( - spatial_grid=self.spatial_grid, medium=self.medium, frame=self.frame - ) - with _optmat.material_context(self._material_log): - return metadata_for( - self.translate(), - self._run_request(), - self.grid, - modes=self.modes, - configuration=configuration, - noise=self.noise, - modal=self._modal_identity(), - materials=self.material_manifest(), - quadrature=self.settings.quadrature, - frame=self.frame, - noise_resolution=self.noise_resolution, - defaults=self.settings.defaults_resolution(), - ) + if isinstance(space, LineGrid): + # The run's powers and energies are per metre of y; processing and + # plotting label them from this key, as for a slab. + simulation.configuration["transverse_measure"] = space.transverse_measure + return simulation - def material_manifest(self) -> MaterialManifest: - """Collect the run's material manifest: what it took from optmat, and how. + @classmethod + @_one_material_context + def from_planar( + cls, + grid: TimeGrid, + space: PlanarGrid, + responses: Sequence[object], + initial: PlanarPulse | Pulse | np.ndarray, + *, + settings: SimulationSettings, + frame: Frame | None = None, + materials: MaterialSelection | None = None, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + ) -> "Simulation": + """Build a hybrid planar propagation: slab modes in x, an FFT in y. - Every optmat-backed object the run carries is found where it sits -- the - mixture, the modes' fills and claddings, a planar grid's slab modes, a guide's - material regions, a bulk medium -- with the selection the run was built with, - the policies in force and what was measured about them: the dilute-scaling error - over the run's whole band, collars included, the loss a transparent solid - dropped, and the bands outside a model's qualified intervals that the run's - range warnings named. :meth:`metadata` records it once; call it after - :meth:`translate` for a complete measurement. After :meth:`run` it also - carries the post-run diagnostic of each optmat ionisation rate - (``peak_fields``), which the file's record, written before the run, does not: - the run writes it beside the record instead, as the output's post-run - annotation :data:`~solprop.output.schema.PEAK_FIELDS_ANNOTATION`, outside the - configuration hash. - - Returns - ------- - solprop.physdata.selection.MaterialManifest - The manifest; empty for a run that takes nothing from optmat. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, raman=True, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, - ... ) - >>> [record.name for record in simulation.material_manifest().records] - ['kerr/SiO2', 'silica_raman/hollenbeck_2002', 'silica_bundle/default'] - """ - # The band edge of a dispersive law depends on whether the node field is a - # vector: the translation decides it on the modal and spatial routes, and a - # general modal guide's node field always has three components. - vector = self.general is not None and self.general.dispersive is not None - leaf = None - if any(isinstance(one, DispersiveKerrResponse) for one in self.responses): - problem = self.translate() - vector = problem.transform.n_pol * problem.shape[1] > 1 - leaf = problem.responses.dispersive_kerr - elif vector: - leaf = self.translate().responses.dispersive_kerr - with _optmat.material_context(self._material_log): - return material_manifest( - ( - self.configuration, - self.modes, - self.responses, - self.medium, - self.spatial_grid, - self.general, - self.guide, - self.wall, - ), - selection=self.materials, - omega=self.grid.omega, - log=self._material_log, - peak_fields=self._peak_fields, - carrier=self.grid.reference_wavelength, - active_band=(0.5 * self.grid.omega_min, self.grid.omega_max_window), - vector=vector, - leaf=leaf, - ) - - def _modal_identity(self) -> Mapping[str, JsonValue] | None: - """Return the modal identity of this run's record, or none at all. + The hollow planar guide resolved in both transverse directions: the slab modes + of `space` in x and its periodic y axis, exact (non-paraxial) in y, so + whole-beam self-focusing and the modulational instability in y -- which a slab + uniform in y cannot show -- are in the model. The fill is the modes' own, as a + bulk medium; it and the gap must be constant along z. The geometry integrates + over y, so energies are in J and powers in W. The collar in y absorbs only when + `settings` declares an absorber, as for :meth:`from_free`. - Three routes declare one: a general modal guide, whose identity names the - provider, the tables and the basis; a capillary carrying a coherent wall block, - whose identity names the flow instead; and a multimode capillary taper carrying - the taper coupling without a wall, whose identity names that flow - (:func:`solprop.translate.coupled.taper_identity`). A run with none carries no - identity. + Parameters + ---------- + grid : solprop.grid.TimeGrid + Field or envelope time grid, in SI units. + space : solprop.modes.slab.PlanarGrid + The slab modes (one gap, one polarisation family, one constant fill) and + the y axis with its collar. + responses : collections.abc.Sequence + Existing Kerr, plasma or explicitly declared Raman response objects, + scaled by the fill's number density. + initial : solprop.pulses.PlanarPulse or numpy.ndarray + The astigmatic launch, or an internal ``(p, k_y)`` spectrum of slab + amplitudes in sqrt(W/m), shape ``(n_p*n_y, 1, n_omega)``, the mode + index fastest. Any other pulse is refused by name. + settings : SimulationSettings + Step tolerances, positive save positions in m, statistics and backend. + Absolute tolerance has the input spectrum's units, sqrt(W/m). + frame : solprop.linear.Frame or None, optional + Moving frame. None uses the declared responses' THG choice, following + the first mode at ``k_y = 0`` as the modal route does. + materials : solprop.physdata.selection.MaterialSelection or None, optional + Take the slab modes' built-in materials from optmat under this selection + and record it in the run's manifest: a fill of a + built-in gas with its default susceptibility takes the selection's + single-species susceptibility (no mixture is needed), and a slab without + a declared cladding takes optmat's recommended fused silica (SEP 3, Q5). + A supplied susceptibility or cladding is taken as it is. An optmat + rate's energy per event is bound to the input's peak field (SEP 3, Q14), + and the input is checked against each rate. ``None`` (the default) is + ``MaterialSelection()``. + absorber : solprop.boundaries.Absorber, False or None, optional + Overrides `settings`' absorber, which must then declare none. ``False`` + switches the temporal and spectral collars off and keeps the hard band + mask; the transverse collars keep their own settings (SEP 21 section 5). + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + Overrides `settings`' temporal scheme, which must then be the default. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges (SEP 21), when `settings` + name the default check: the default warns, then stops, when light above + the shot-noise floor reaches them; ``None`` watches nothing. Refused + beside settings that name another check. - The wall block's validity ratios are re-derived here from the mode set rather - than carried out of the translation layer: they are a handful of Bessel zeros - over a wavenumber, the record must hold scalars and not arrays, and a second - evaluation of the same declared geometry cannot disagree with the first. It - is the **second** evaluation, though, so it says nothing the first did not: - ``translate()`` above has already built the same block and raised the same - grazing-incidence and material-range warnings, and a caller warned twice - about one geometry would reasonably look for two of them. They are silenced - here and nowhere else. + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional + The transverse boundary (SEP 40), when `settings` declare none: ``None`` + keeps the settings' own, by default the safety ramp alone; a + :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in + the grid's room outside its requested size; ``False`` carries no ramp. - For a guide that varies along z the scan is over the **edges and the midpoints - of the flow's own sections**: the midpoints are where the frozen blocks were - built, and the edges are where a monotone profile is extreme, so the recorded - ratios are the worst geometry the run actually reaches rather than the - entrance's, and they are the geometry the admission check warns about. Scanning - the midpoints alone would record a radius the run merely passes through -- on a - 50 to 30 micrometre linear taper, 40 rather than 30 micrometres, a third - optimistic in every ratio (:func:`solprop.modes.capillary.worst_wall_limits`). + Returns + ------- + Simulation + Cached translation and the ordinary resumable execution interface. - - Returns: the ``modal`` record, or ``None``. + Raises + ------ + DriverError + If the fill varies along z or is a mixture, the input is neither a + :class:`solprop.pulses.PlanarPulse` nor a spectrum of the planar shape. """ - if self.general is not None: - return modal_identity( - self.general, - self.grid, - channels=[mode.channel_label for mode in self.modes], - has_noise=self.noise is not None, - # A declared basis of a split block puts a coupled flow on the - # general route too, and a file that did not say so could resume - # against a different block in the same gauge. - flow=self.translate().linear.coupled_flow, - ) - flow = self.translate().linear.coupled_flow - if flow is None: - return None - if self.wall is None: - # The taper coupling of a multimode capillary taper: - # the only coupled flow a mode-set route builds without a wall. - problem = self.translate() - return taper_identity( - flow, - channels=[mode.channel_label for mode in self.modes], - active=self.grid.active_mask, - scored=problem.grids.inband_mask, - n_components=problem.transform.n_pol, - has_noise=self.noise is not None, + materials = MaterialSelection() if materials is None else materials + space = _selected_planar(space, materials) + medium = _planar_medium(space) + if not isinstance(initial, PlanarPulse | np.ndarray): + msg = ( + "a planar run launches a PlanarPulse (waist_x projected onto the slab " + "modes, waist in y) or takes a built (p, k_y) spectrum; got " + f"{type(initial).__name__}" ) - edges = np.asarray(flow.z_edges, dtype=float) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", GrazingIncidenceWarning) - warnings.simplefilter("ignore", MaterialRangeWarning) - limits = worst_wall_limits( - cast("Sequence[MarcatiliMode]", self.modes), - self.grid.omega, - positions=np.unique( - np.concatenate([edges, 0.5 * (edges[:-1] + edges[1:])]) - ).tolist(), - active=self.grid.active_mask, - scored=self.translate().grids.inband_mask, - validity_limit=self.wall.validity_limit, - impedance=self.wall.impedance, + raise DriverError(msg) + chosen_frame = frame + if chosen_frame is None: + chosen_frame = Frame.comoving( + grid, thg=declares_harmonic_carrier(responses, grid) ) - return wall_identity( - flow, - limits, - channels=[mode.channel_label for mode in self.modes], - impedance=self.wall.impedance, - scored=self.translate().grids.inband_mask, - n_components=self.translate().transform.n_pol, - has_noise=self.noise is not None, - ) - - def _boundary_monitor(self) -> BoundaryMonitor | None: - """Return the boundary monitor of a fresh run, or ``None`` without a check.""" - check = self.settings.boundary_check - if check is None: - return None - boundary = self.settings.absorber - rate = ( - None - if boundary is None or not np.any(boundary.temporal_rate) - else boundary.temporal_rate - ) - space = self.spatial_grid - columns = ( - np.asarray(space.hankel.spectral_weights, dtype=np.float64) - if isinstance(space, RadialGrid) - else None + if isinstance(initial, np.ndarray): + # Refused before the simulation copies it. + _check_planar_input(initial.shape, space.n_columns, grid.n_omega) + settings = _spatial_default( + _bounded( + _watched( + _with_boundary(settings, grid, absorber, absorber_scheme), + boundary_check, + ), + spatial_absorber, + ), + grid, ) - monitor = BoundaryMonitor.build( - self.grid, - rate, - check, - columns=columns, - split=rate is not None and _scheme(self.settings) == "split_step", + simulation = cls( + grid, + (), + responses, + initial, + settings=_spatial_settings(settings, "planar"), + noise_resolution=NoiseResolution("default", None, PLANAR_NOISE), + profiles={"density": medium.density}, + spatial_grid=space, + medium=medium, + frame=chosen_frame, + materials=materials, ) - if isinstance(space, RadialGrid | FreeGrid | PlanarGrid) and ( - self.medium is not None - ): - monitor.transverse = self._transverse_edge(check, space, self.medium) - return monitor + _check_planar_input(simulation._field0.shape, space.n_columns, grid.n_omega) + _bind_spatial_rates(simulation, initial) + return simulation - def _transverse_edge( - self, - check: BoundaryCheck, - space: RadialGrid | FreeGrid | PlanarGrid, - medium: SpatialMedium, - ) -> TransverseEdge: - """Return the transverse edge a free-space run's monitor reads (SEP 40 5.3).""" - problem = self.translate() - planes = self._run_request().observation.save_planes - length = float(np.max(planes)) - float(problem.initial.z) - k_min = self._edge_k_min.get(length) - if k_min is None: - dispersion = SpatialDispersion(medium, self.grid, Frame.comoving(self.grid)) - k, _ = band_wavenumbers(space, dispersion, max(length, 0.0)) - k_min = self._edge_k_min[length] = float(np.min(k)) - grid = self.grid - return TransverseEdge( - space, - transverse_of(problem.route), - check.edge_budget, - check.stop_factor, - length, - np.nonzero(grid.inband_mask & grid.active_mask)[0], - k_min, - ) - - def run( - self, - output: Output | Path | str | None = None, + @classmethod + @_one_material_context + def from_gnlse( + cls, + gamma: float | None, + length: float, + betas: Sequence[float] | np.ndarray | None = None, *, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, - resume: _ResumeChoice = False, - tdse_archive: TDSEArchive | None = None, - snapshots: SnapshotController | None = None, - stop: StopFlag | None = None, - ) -> Output: - """Execute the run and return the handler it was written to. + wavelength: float, + wavelength_limits: tuple[float, float] | None = None, + trange: float | None = None, + grid: TimeGrid | None = None, + beta: tuple[Float[np.ndarray, " n_nodes"], Float[np.ndarray, " n_beta_nodes"]] + | Callable[[Float[np.ndarray, " n_omega"]], Float[np.ndarray, " n_omega"]] + | None = None, + fibre: Mode | IndexTable | None = None, + n2: float | str | None = None, + fwhm: float | None = None, + width: float | None = None, + shape: str = "gauss", + energy: float | None = None, + peak_power: float | None = None, + phases: Sequence[float] | np.ndarray = (), + pulses: Sequence[Pulse] | None = None, + loss_db_per_m: float | None = None, + shock: bool = True, + raman: bool = False, + raman_fraction: float | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + rtol: float | None = None, + atol: float = DEFAULT_ATOL, + dz_max: float | None = None, + dz_init: float | None = None, + schedule: FixedSchedule | None = None, + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, + interpolate_planes: bool = False, + step_planes: int | None = None, + save_planes: Sequence[float] | np.ndarray | None = None, + n_save: int | None = None, + stats: StatsSpec | None = None, + polarisation_taper: bool = False, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + backend: str = "auto", + materials: MaterialSelection | None = None, + ) -> "Simulation": + """Build a simulation of a GNLSE fibre from its coefficients. + + The constructor :func:`solprop.driver.interface.prop_gnlse` calls; its docstring + documents every keyword, and this method takes them unchanged. The GNLSE is not + a separate solver path: it is the mode-averaged envelope problem with a + dispersion-specified mode, ``density = 1``, ``prefactor_index = 1`` and the + shock term supplied by the prefactor's ``omega`` dependence + (``docs/guide/waveguides/gnlse.md``). Parameters ---------- - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Where the planes go: a handler, a path to write an HDF5 file at, or ``None`` - for a :class:`solprop.output.memory.MemoryOutput`. The handler is closed - when the run ends, so a file handler is returned closed and is read back - with :func:`solprop.output.hdf5.open_file`; a memory handler stays readable. - A run with optmat ionisation rates then annotates it with their peak-field - report (:data:`~solprop.output.schema.PEAK_FIELDS_ANNOTATION`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a - :class:`solprop.log.BarReporter` on a terminal or in a notebook, a - :class:`solprop.log.LogReporter` otherwise - (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the line reporter's periodic lines, s; ``0`` leaves only - the plane and closing lines. A bar has its own redraw period and a given - reporter its own business. - resume : bool or {'interpolated'}, optional - Continue the file at `output` instead of replacing it, when its record and - this run's have one configuration hash (``docs/guide/results/resuming.md``, - ADR-066); otherwise it is refused, naming the fields that differ. A finished - file is returned. Only a path can be resumed: the driver has to open the - file in append mode itself. Noise a constructor drew without a seed is drawn - again from the seed the file recorded, so re-running the same call continues - its file. A file whose last plane was interpolated inside a step is refused - unless this is ``"interpolated"``: it then continues afresh from the - interpolated field, and the position is recorded in the annotation - :data:`~solprop.output.schema.INTERPOLATED_RESUMES_ANNOTATION`. - tdse_archive : solprop.output.TDSEArchive or None, optional - Archive every raw TDSE provider evaluation of a run with a TDSE - response in this separate HDF5 file (SEP 20 section 5.8); ``None`` - archives nothing. It is opened and validated before any atomic work, a - resume appends a new session to its own run's archive, and the optical - output records the archive's run UUID and relative path. It changes - neither the propagation nor the call ledger. - snapshots : solprop.output.SnapshotController or None, optional - Publish immutable local checkpoints without rebuilding the backend or - atomic provider, each at the next accepted endpoint, after every - interpolated plane its step crossed. Requires HDF5 and ``fields='all'``. - Adams history cannot be explicitly disabled. A configured archive is - copied together with the optical file. - stop : object or None, optional - This run's own stop flag: any object with an ``is_set()`` method, such as - a :class:`threading.Event` (SEP 41, S17). It is polled before the first - step and before every trial step, so the run answers within one step or - trial; on a native ``Multistep()`` march, whose trials run in native calls - that cannot read it, before every call, so the run answers within 256 - attempts or 0.1 s and the attempt in flight, whichever comes first. When it - is set the run writes its state as for SIGTERM, tells the - reporter through ``interrupted``, and returns the handler: a requested - stop is not an error. The outcome's message says the caller's flag - stopped it, and ``resume=True`` continues the file. A front end sets it - from any thread on any platform, without signals; each run polls its own. - - Returns - ------- - solprop.output.schema.Output - The handler the run was written to, complete or, after `stop` was set, - holding the state where it stopped. - - Raises - ------ - ValueError - If `resume` is not one of its three values. - DriverError - If a resume is asked for and `output` is not a path, or the file's last - plane was interpolated and `resume` is not ``"interpolated"``; or - `tdse_archive` is given without a TDSE response or at the - path of the optical file. - TDSEArchiveError - If the archive's path holds another file, or a write to it fails; the - run then stops at its last optical checkpoint. - OutputError - If a file is there and its configuration hash differs; the message names - what changed. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, - ... ) - >>> handler = simulation.run() - >>> handler.n_saved, handler["z"].tolist() - (3, [0.0, 0.005, 0.01]) - """ - return self._run( - output, - progress=progress, - progress_period=progress_period, - resume=resume, - tdse_archive=tdse_archive, - snapshots=snapshots, - stop=stop, - ) - - def _run( # noqa: PLR0915 - run's controls, and the backend it may be handed - self, - output: Output | Path | str | None = None, - *, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, - resume: _ResumeChoice = False, - tdse_archive: TDSEArchive | None = None, - snapshots: SnapshotController | None = None, - stop: StopFlag | None = None, - kept: Backend | None = None, - ) -> Output: - """Execute the run, on a new backend or on one a caller keeps between runs. - - - ``progress_period``: as for :meth:`run`, s. - - ``kept``: a backend built for this problem but for its input, which is loaded - and left open for the caller; ``None`` constructs one and closes it. It serves - a fresh run only, with no TDSE response. - """ - _check_resume_choice(resume) - with _optmat.material_context(self._material_log): - if resume: - self._adopt_recorded_seed(output) - problem = self.translate() - request = self._run_request() - _refuse_pending_check(problem) - metadata = self.metadata() - handler, state = _open_output(output, metadata, resume=resume) - if state is not None: - next_plane = _validate_resume( - problem, request, state, self.settings.tdse_execution - ) - if not _continues( - Path(cast("Path | str", output)), - request, - state, - next_plane, - resume, - ): - handler.open(state.metadata) - handler.close() - return handler - _warn_sampled_peak(self.settings.quadrature, request.observation.stats) - retention = field_retention( - request, self.settings.fields, self.settings.field_planes - ) - if tdse_descriptor(problem.route) is not None and not isinstance( - handler, CheckpointOutput - ): - raise DriverError( - "TDSE output must support terminal checkpoints and call ledgers" - ) - _check_tdse_archive(tdse_archive, problem, handler) - check_snapshot_request( - request, - handler, - snapshots, - self.settings.resume_history, - retention.fields, - ) - if ( - snapshots is not None - and tdse_descriptor(problem.route) is not None - and state is not None - and _recorded_archive_uuid(state) is not None - and tdse_archive is None - ): - raise DriverError( - "snapshots require the archive referenced by " - "the resumed optical file" - ) - backend: Backend | None = None - binding: TDSEBinding | None = None - archive: TDSEArchiveWriter | None = None - failure: BaseException | None = None - try: - if tdse_descriptor(problem.route) is not None: - binding, archive = self._prepare_atomic( - problem, - _AtomicContext(handler, state, metadata), - tdse_archive, - ) - backend = kept or select( - self.settings.backend, - problem, - request, - grid=self.grid, - modes=self.modes, - profiles=self.profiles, - tdse_binding=binding, - options=self.settings.execution_options(), - ) - restoration: dict[str, object] = {} - start = self._start( - backend, - problem, - state, - kept=kept is not None, - restoration=restoration, - ) - self._restore_atomic(binding, start) - self._consistency_gate(binding, problem, handler, metadata, backend) - run_problem( - problem, - backend, - handler, - start=start, - metadata=metadata, - stats_spec=request.observation.stats, - progress=progress, - progress_period=progress_period, - tdse_binding=binding, - resume_history=self.settings.resume_history, - fields=retention.fields, - field_planes=retention.field_planes, - snapshots=snapshots, - tdse_archive=archive, - boundary_monitor=self._boundary_monitor(), - stop=stop, - recipe=None if self._recipe is None else self._recipe.record(self), - restoration=restoration or None, - ) - except StopRequested as error: - failure = error - # A stop raised by another run's flag is that run's to catch. - if error.flag is not stop: - raise - except BaseException as error: - failure = error - raise - finally: - _close_run( - None if kept is not None else backend, - binding, - archive, - handler, - failure, - ) - self._peak_fields = _peak_field_reports(problem, handler, self.responses) - if ( - state is not None - and _interpolated(state) - and isinstance(handler, PlaneStore) - ): - # Allowed only by resume="interpolated"; the record says where. - earlier = handler.annotations.get(INTERPOLATED_RESUMES_ANNOTATION, []) - handler.annotate( - INTERPOLATED_RESUMES_ANNOTATION, - [*cast("list[JsonValue]", earlier), float(state.plane.z)], - ) - if self._peak_fields and isinstance(handler, PlaneStore): - # Learned only now the run has closed, so outside its record and its - # configuration hash: a post-run annotation (SEP 3, Q13). - handler.annotate( - PEAK_FIELDS_ANNOTATION, - [cast("JsonValue", report.to_dict()) for report in self._peak_fields], - ) - return handler - - def estimate(self) -> "RunEstimate": - """Estimate what the run would cost, without running it (SEP 41, S11). - - The grid, the persistent memory the constructed backend holds, the output's - size, the cost of one right-hand-side evaluation on the backend, timed after an - untimed warm-up that takes compilation and planning, and the integrator's calls - per step (:mod:`solprop.driver.estimate`). It constructs the backend, so a run - that doesn't fit in memory fails here as the run would. + gamma : float or None + Nonlinear coefficient in 1/(W m). + length : float + Fibre length, m, positive. + betas : array_like, optional + Taylor coefficients of ``beta`` about `wavelength`, ``beta_0, beta_1, ...`` + in s^n/m. Give exactly one of this, `beta` or `fibre`. + wavelength : float + Reference wavelength, m: the ``gamma`` mapping's, the Taylor expansion's, + the grid's and the pulse's centre. + wavelength_limits : tuple of float, optional + Shortest and longest wavelength of the frequency window, m. Required unless + `grid` is given. + trange : float, optional + Extent of the time window, s. Required unless `grid` is given. + grid : solprop.grid.TimeGrid, optional + An envelope grid to use instead of building one. A field grid is refused. + beta : tuple or callable, optional + The propagation constant as data: a pair of arrays ``(omega, beta)`` in + rad/s and 1/m, or a callable of ``omega`` sampled onto the grid's active + band. Both go through :class:`solprop.nonlinear.gnlse.TabulatedDispersion`. + fibre : Mode or IndexTable, optional + Whole dispersion and loss instead of `betas` or `beta`. A mode goes + through :meth:`solprop.nonlinear.gnlse.SpecifiedMode.from_mode`, so one + whose geometry moves along z -- a tapered mode, a moving guide's + provider channel, a ``SpecifiedMode`` with dispersion and area paths -- + hands over its dispersion and its area along z; only the fixed-step + scaffold requires a z-constant fibre. Its fill is ignored as a + nonlinear material: the GNLSE uses gamma or n2, retaining the mode only + for dispersion, loss and area. A table's area is evaluated at + `wavelength`, and so is a provider channel's, by a fresh solve there (along + the path of a moving guide); without an area column a table requires + `gamma` and uses the 1 m^2 convention. + n2 : float or str, optional + Nonlinear index in m^2/W, requiring `fibre` and ``gamma=None``. + Exactly one of `gamma` and `n2` is required. The mapping is + ``gamma = n2 omega0 / (c A_eff)``, with unit prefactor index. Under + `materials`, ``"recommended"`` takes optmat's silica bundle's total + ``n2`` at `wavelength`. + fwhm : float, optional + Intensity full width at half maximum of the input, s. + width : float, optional + Natural width of a ``sech**2`` input, s. Give this or `fwhm`. + shape : {'gauss', 'sech'}, optional + Shape of the input. + energy : float, optional + Pulse energy, J. Give this or `peak_power`. + peak_power : float, optional + Peak power, W. + phases : array_like, optional + Optics-convention spectral phase about `wavelength`: CEP in rad, then group + delay, GDD, TOD, ... in s^n. + pulses : sequence of solprop.pulses.Pulse or None, optional + Several pulses launched together, for two-colour and multi-colour + inputs: each a :mod:`solprop.pulses` object with its own wavelength, + duration, energy or peak power, phase and delay; the input is the sum of + their fields (:class:`~solprop.pulses.PulseSum`). Replaces the + single-pulse keywords `fwhm`, `width`, `shape`, `energy`, + `peak_power` and `phases`, which must then be left unset; `wavelength` still + sets the grid's reference wavelength. + loss_db_per_m : float or None, optional + Omitted means zero loss on the coefficient routes. Refused beside + `fibre`, which carries its own loss. Power loss in dB/m, converted by + :func:`solprop.nonlinear.gnlse.attenuation_from_db`. + shock : bool, optional + Keep the prefactor's ``omega`` dependence, which is the shock term. + raman : bool, optional + Whether a fused-silica Raman response takes `raman_fraction` of ``gamma``. + raman_fraction : float or None, optional + Share of ``gamma`` held back from the Kerr term, between 0 and 1. Omitted, + the silica bundle's ``f_R`` with `raman` true and 0 with it false; given, + held back either way. + noise : {'default', None, 'input', 'modified'}, optional + Which shot-noise model to carry (``docs/guide/media/noise.md``). + ``"default"``, the default, resolves to ``"input"`` wherever the run can + carry noise, and records the resolution (SEP 37). + seed : int, optional + Entropy of the generator the realisation is drawn from, recorded in the + output so a run is reproducible. + rtol : float or None, optional + Relative tolerance of the step controller. + atol : float, optional + Absolute tolerance, in the square root of watts. + dz_max : float, optional + Cap on the controller's proposal, m; ``None`` uses half the length. + dz_init : float, optional + First step, m; ``None`` uses a ten-thousandth of the length. + schedule : solprop.stepper.schedule.FixedSchedule, optional + A fixed schedule to replay instead of stepping adaptively + (``docs/dev/stepper.md`` section 8). + integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional + The method that steps the run + (``docs/guide/numerics/choosing-a-stepper.md``). + ``None``, the default, takes ``RungeKutta()``, the interaction-picture + DOPRI5 march, which SEP 37 measured fastest at matched accuracy on the + GNLSE. ``Multistep()`` is the low-call exponential Adams method, one + response evaluation per step. ``LowStorageRungeKutta()`` replays + `schedule` in three registers (SEP 24). + interpolate_planes : bool, optional + Observe the save planes inside accepted steps instead of landing on them + (SEP 14): through the Adams march's own polynomial, or DOPRI5's + continuous extension with ``RungeKutta()``. Default ``False``. + step_planes : int or None, optional + Also save the end of every `step_planes`-th accepted step, the + march's own positions, beside the requested planes (SEP 14); + ``None``, the default, and ``0`` save the requested planes only. + save_planes : array_like, optional + Positions to save at, m. Give this or `n_save`. + n_save : int, optional + Number of planes to store, counting the one at ``z = 0``, spread evenly over + the fibre. Default 201. + stats : solprop.stats.StatsSpec, optional + Which statistics to record; ``None`` records + :func:`solprop.stats.default_spec` of a mode-averaged run. + polarisation_taper : bool, optional + Whether the spectral collar tapers the nonlinear polarisation. Default + ``False``: the taper costs a systematic error of the size of the field's + content in the collar, and the rate absorber acts either way. + absorber : solprop.boundaries.Absorber, False or None, optional + The collar rates; ``None`` builds the default collar of the grid + (:func:`~solprop.driver.settings.default_collar`), and ``False`` switches + both soft collars off and keeps the hard band mask + (:func:`solprop.boundaries.no_absorber`). + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + How a temporal collar is applied (ADR-025); ``None`` is the default, + inside every stage. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges (SEP 21): the default warns, + then stops, when light above the shot-noise floor reaches them; ``None`` + watches nothing, for a window kept narrow on purpose. + backend : str, optional + Registry name, or ``"auto"`` (the default) for ``native``, which refuses if + it isn't installed or this processor is below its baseline. There is no + fallback, and import or construction errors stay visible. + materials : solprop.physdata.selection.MaterialSelection or None, optional + Take fused silica's nonlinear response from optmat's silica bundle + (:class:`~solprop.physdata.glasses.SilicaBundle`): with `raman` on, an + omitted fraction is the bundle's ``f_R`` and the response its sampled + shape; ``n2="recommended"`` is its total ``n2``. The fibre's dispersion, + area and a given ``gamma`` or ``n2`` are the caller's. The run records + its manifest, the held-back fraction and its origin + included. ``None`` (the default) is ``MaterialSelection()``. Returns ------- - solprop.driver.estimate.RunEstimate - The estimate. - """ - from solprop.driver.estimate import estimate # noqa: PLC0415 - imports this - - return estimate(self) + Simulation + Ready to :meth:`run`. - def calibrate(self, budget: float = 60.0) -> "RefinementReport": - """Refine the native backend's settings for this simulation (SEP 46 stage 2). - - Fresh native workspaces of this simulation's translated problem are built in - this process under candidate settings and timed by trial steps along its - length; the settings that win, stay accurate and validate together are stored - under its execution descriptor, and every later run or scan point of the same - execution applies them. The simulation itself is not changed. Running - ``solprop calibrate`` first gives the machine profile this refines - (:mod:`solprop.calibration`). - - Parameters - ---------- - budget : float, optional - The deadline, in seconds. - - Returns - ------- - solprop.calibration.refine.RefinementReport - What was kept and whether it was stored. - """ - from solprop.calibration.refine import refine # noqa: PLC0415 - imports native - - return refine(self, budget) - - def output_bytes(self) -> int: - """Return the size of the run's output, without constructing a backend. - - The cheap part of :meth:`estimate`, for asking as the setup changes: it times - nothing and allocates no run state. - - Returns - ------- - int - Bytes, as an in-memory store holds them (an HDF5 file adds little). + Raises + ------ + DriverError + If the fibre, the grid, the input or the save planes are specified in a way + this interface does not accept; the message names what is wrong. Examples -------- - >>> from solprop.recipe import Recipe - >>> simulation = Recipe.example("soliton").build() - >>> simulation.output_bytes() == simulation.estimate().output_bytes - True - """ - from solprop.driver.estimate import output_bytes # noqa: PLC0415 - imports this - - return output_bytes(self) - - def _prepare_atomic( - self, - problem: PropagationProblem, - context: _AtomicContext, - tdse_archive: TDSEArchive | None, - ) -> tuple[TDSEBinding, TDSEArchiveWriter | None]: - """Open a TDSE run's archive and prepare its binding. - - - Returns: the binding and the open archive session, or None without a request. - A failure closes the archive before it propagates. - """ - descriptor = tdse_descriptor(problem.route) - assert descriptor is not None - _warn_tdse_cost(self.settings.quadrature) - execution = self.settings.tdse_execution - if execution is None: - raise DriverError("TDSE requires explicit tdse_execution settings") - # Opened and validated before any atomic work (SEP 20, 5.8). - archive = _open_tdse_archive( - tdse_archive, - problem, - context.handler, - context.state, - context.metadata, - execution, - ) - try: - # A haCC binding builds its propagation plans only after the - # consistency check, so the check's one-row plan never adds to their - # peak memory (_consistency_gate). - binding = tdse_runtime.prepare_binding( - descriptor, - execution, - problem.transform.n_nodes, - defer_plans=descriptor.model == "hacc", - archive=archive, - ) - except BaseException as error: - _close_tdse_archive(archive, error) - raise - return binding, archive - - @staticmethod - def _restore_atomic(binding: TDSEBinding | None, start: Start) -> None: - """Restore a resumed run's cumulative atomic accounting into its binding. - - Before anything can charge the binding: a haCC run's consistency gate makes a - diagnostic provider call when its local cache has no entry, and a budget that - has begun work refuses to be restored. The start keeps only the logical - ledger, which the session restores when it loads the start. - - - ``binding``: the run's open atomic binding; None for a run without one. - """ - ledgers = start.ledgers - if binding is None or not ledgers or not ledgers.get("atomic"): - return - atomic = cast("Mapping[str, object]", ledgers["atomic"]) - binding.restore_ledger(AtomicCallLedger.from_record(atomic)) - start.ledgers = {"logical": ledgers.get("logical", {})} - - def _consistency_gate( - self, - binding: TDSEBinding | None, - problem: PropagationProblem, - handler: Output, - metadata: RunMetadata, - backend: Backend, - ) -> None: - """Check a haCC run's weak-field linear response before propagating. - - The cached or computed result is attached to the binding's provenance, under - ``tdse.consistency``. The binding was prepared with deferred plans, so the - check's temporary one-row plan is closed before the propagation plans are - built here. A refused or invalid result is written to the requested - output, as the opened run record without any plane or checkpoint, before - :class:`~solprop.exceptions.TDSEConsistencyError` is raised (SEP 20, 5.4). - - - ``binding``: the run's open atomic binding; None for a run without one. + >>> from solprop.driver import Simulation + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + ... ) + >>> simulation.settings.save_planes.tolist() + [0.005, 0.01] """ - descriptor = tdse_descriptor(problem.route) - if binding is None or descriptor is None or descriptor.model != "hacc": - return - declared = next( - one for one in self.responses if isinstance(one, TDSEGasResponse) - ) - if not isinstance(binding, tdse_consistency.GateBinding): - raise DriverError("a haCC run needs a binding with a diagnostic row") - build = cast("dict[str, object]", binding.metadata().get("build", {})) - native = cast("dict[str, object]", build.get("native", {})) - result = tdse_consistency.gate_run( - binding, - declared, - reference_wavelength=self.grid.reference_wavelength, - provider_revision=tdse_runtime.pinned_revision(), - computed_on={ - "execution_space": native.get("execution_space"), - "build": build, - "threads": native.get("requested_threads"), - }, + materials = MaterialSelection() if materials is None else materials + _refuse_dispersive_policy(materials, "a GNLSE run's fitted gamma") + working = _gnlse_grid( + grid, + length, + wavelength, + wavelength_limits, + trange, ) - binding.record_consistency(result.to_record()) - if result.outcome in ("refused", "invalid"): - try: - open_run_record(handler, metadata, backend, binding) - except Exception: - logger.exception("could not record the refused consistency check") - tdse_consistency.apply_outcome(result) - # The diagnostic's temporary plan is closed: only now the propagation plans. - if not binding.plans_built: - binding.build_plans() - - def _reseed(self, seed: int) -> bool: - """Draw a noise realisation that was drawn without a seed again, from `seed`. - - A constructor given ``seed=None`` draws operating-system entropy, so building - the same call twice gives two realisations and two configuration hashes. A - resume (:meth:`run`) and a scan point (:func:`~solprop.driver.scan.run_scan`) - pass the seed that makes the run the one they continue or reproduce. A - realisation the caller seeded or supplied is never redrawn. - """ - if self._noise_redraw is None or self.noise is None or self.noise.seed == seed: - return False - self.noise = self._noise_redraw(seed) - if self._problem is not None: - self._problem = _with_noise(self._problem, self.noise) - self._metadata = None - return True - - def _reseeded(self, seed: int) -> "Simulation": - """Return this run with unseeded noise drawn from `seed`, leaving it as it is. - - A scan's builder may return one object for every point, and a batch keeps each - member by reference, so a point's realisation must not land on the object the - other points share. Translated first, so that they share its translation. - - - Returns: a copy carrying the new realisation and sharing the translation's - other leaves, with its own material log as any derived run has; or this run, - when there is nothing to draw again (:meth:`_reseed`). - """ - if self._noise_redraw is None or self.noise is None or self.noise.seed == seed: - return self - self.translate() - seeded = copy.copy(self) - seeded._material_log = self._material_log.copy() - seeded._reseed(seed) - return seeded - - def _rebound_noise( - self, medium: SpatialMedium | None - ) -> tuple[ - NoiseRealisation | None, - Callable[[int | None], NoiseRealisation | None] | None, - ]: - """Return this run's noise and its redraw for the same run in a new medium. - - A modal draw depends only on the grid, which the updated run shares. A spatial - draw is measured by the medium's flux, so it is rebuilt there, and the - realisation drawn again from its own seed: what a run carries must be what its - redraw gives, or a resume drawing from the recorded seed would disagree with - its own file. A realisation seeded or supplied by the caller stays as it is. - - - ``medium``: the updated run's medium; ``None`` on a modal route. - """ - noise, redraw = self.noise, self._noise_redraw - if noise is None or redraw is None or medium is None: - return noise, redraw - redraw = _spatial_draw(noise.model, self, medium) - return redraw(noise.seed), redraw - - def _adopt_recorded_seed(self, output: Output | Path | str | None) -> None: - """Take the noise seed of the file a resume continues, where the call gave none. - - The maintainers' ruling on resuming a default call (``seed=None``): the - realisation is drawn again from the seed the file recorded, so the re-run - call describes the run it continues. Nothing happens when there is no file, - the file recorded no seed or another noise model, or the realisation was - seeded or supplied by the caller; :func:`~solprop.output.hdf5.check_cache` - then judges the run as before. - """ - if ( - self._noise_redraw is None - or self.noise is None - or not isinstance(output, Path | str) - or not Path(output).exists() - ): - return - stored = _stored_metadata(output)[0].noise - if stored is None or stored.seed is None or stored.model != self.noise.model: - return - if self._reseed(stored.seed): - logger.info( - "resuming %s: the noise was drawn without a seed, so it is drawn again " - "from the seed the file recorded, %d", - output, - stored.seed, - ) - - def _start( - self, - backend: Backend, - problem: PropagationProblem, - state: ResumeState | None, - *, - kept: bool = False, - restoration: dict[str, object] | None = None, - ) -> Start: - """Return where the run starts: the entrance, or the state a file resumes. - - The run's session loads it (SEP 47): the field, the noise, a resume's call - ledgers and the method history this run continues. - - - ``backend``: the backend about to step, asked only whether its march can carry - a method history. - - ``state``: what a file the run is continuing holds, or ``None`` to start at - the entrance. - - ``kept``: the backend was built for another input, so this one's noise has to - be handed to it. - """ - if state is None: - return Start.entrance( - problem, noise=_realisation(problem) if kept else None - ) - request = self._run_request() - next_plane = _validate_resume( - problem, request, state, self.settings.tdse_execution + _check_selection_carrier(materials, working.reference_wavelength, wavelength) + mapping = _gnlse_fibre( + gamma, + betas, + beta, + working, + wavelength=wavelength, + loss=loss_db_per_m, + fibre=fibre, + n2=n2, + shock=shock, + raman=raman, + raman_fraction=raman_fraction, + selection=materials, ) - logger.info( - "resuming at z = %.6e m, plane %d of %d", - state.plane.z, - state.n_saved, - request.observation.save_planes.size + 1, + pulse = _shaped_pulse( + shape, + wavelength=wavelength, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phases=phases, + pulses=pulses, ) - # A field-free plane resumes only a finished run, which never gets here. - assert state.plane.field is not None - field = state.plane.field - if problem.transform.geometry is None: - field = power_amplitude( - field[np.newaxis], power_weight_at(problem, state.plane.z), inverse=True - )[0] - integrator = request.integration.method - record, march = self._continue_march( - backend, - state, - adams=isinstance(integrator, Multistep), - low_storage=isinstance(integrator, LowStorageRungeKutta), + boundary, scheme = _boundary(absorber, absorber_scheme, working) + controller, default = _keyword_controller("gnlse", rtol, atol) + settings = SimulationSettings( + controller=controller, + _default_controller=default, + _route="gnlse", + save_planes=_save_planes(length, save_planes, n_save), + # Relative defaults, so that the first step and the cap scale with the + # fibre. + dz_max=length / 2 if dz_max is None else dz_max, + dz_init=length * 1e-4 if dz_init is None else dz_init, + auto_init=dz_init is None, + schedule=schedule, + integrator=integrator, + interpolate_planes=interpolate_planes, + step_planes=0 if step_planes is None else step_planes, + density=1.0, + shock=shock, + prefactor_index=mapping.prefactor_index, + stats=default_spec("mode_averaged") if stats is None else stats, + absorber=boundary, + absorber_scheme=scheme, + boundary_check=boundary_check, + polarisation_taper=polarisation_taper, + backend=backend, ) - if restoration is not None: - restoration.update(record) - return Start( - field=field, - z=float(state.plane.z), - plane_index=next_plane, - march=march, - ledgers=None - if state.outcome is None - else { - "logical": state.outcome.logical_calls, - "atomic": state.outcome.atomic_calls, + noise_model, resolution = resolve_noise("gnlse", noise, seed) + draw = functools.partial(_shot_noise, noise_model, working) + simulation = cls( + working, + mapping.mode, + list(mapping.responses), + pulse, + settings=settings, + noise=draw(seed), + noise_resolution=resolution, + configuration={ + "gnlse": mapping, + "loss_db_per_m": 0.0 if loss_db_per_m is None else loss_db_per_m, }, - resume=True, - ) - - def _continue_march( - self, - backend: Backend, - state: ResumeState, - *, - adams: bool = True, - low_storage: bool = False, - ) -> tuple[dict[str, object], MarchSnapshot | None]: - """Decide whether the Adams history continues from the file's snapshot. - - Says what is restored, for the resumed execution record (SEP 47 section 5.5), - and warns once when an Adams history the run expected is not restored: - omitted by the automatic size limit, or kept where this backend's march - cannot carry it. A history set aside on request (``resume_history=False``) is - recorded without a warning. A history handed on is installed by the run's - session, which warns and records a reset if it cannot - (:func:`solprop.driver.run.run_problem`). - - - Returns ``record``: ``"state"`` (``"restored"``, ``"reset"`` or - ``"not_applicable"``), ``"reason"`` and ``"z"``, m; for an Adams method also - ``"retention"``, what the file states it kept there, ``{"state": "unknown"}`` - where it states nothing. - - Returns ``march``: the history the run's session is to install, or ``None``. - """ - z = state.plane.z - if low_storage: - record = {"state": "not_applicable", "reason": "no_method_history", "z": z} - return record, None - if not adams: - # The proposal and the norm memory start afresh (issue #596). - return {"state": "reset", "reason": "unsupported_method", "z": z}, None - execution = None if state.outcome is None else state.outcome.execution - recorded = retention_at(execution, z) - retention: dict[str, object] = ( - dict(recorded) - if recorded is not None - else { - "state": "unknown", - # A file from before the restart record, or one that states nothing - # at this position: never an invented reason. - "reason": "legacy" - if execution is None or RESTART_RECORD not in execution - else "not_recorded", - } + materials=materials, ) - - def reset(reason: str) -> dict[str, object]: - """Return the restoration record of a history that starts afresh. - - - Returns: the record's ``"state"``, ``"reason"``, ``"z"`` and - ``"retention"``. - """ - return {"state": "reset", "reason": reason, "z": z, "retention": retention} - - if self.settings.resume_history is False: - if state.march is not None: - logger.info("the Adams history starts afresh: resume_history=False") - return reset("explicit_policy"), None - # A march that cannot carry its history restarts it without having been asked - # to: say so once, as a warning (SEP 29 section 6.6). - refusal = backend.march_export_refusal() - if refusal is not None: - warnings.warn( - f"the Adams history starts afresh: {refusal}", - SolpropWarning, - stacklevel=2, - ) - return reset("incompatible_representation"), None - if state.march is None: - return self._missing_history(retention, reset), None - restored: dict[str, object] = { - "state": "restored", - "reason": "retained_history", - "z": z, - "retention": retention, - } - return restored, state.march - - @staticmethod - def _missing_history( - retention: Mapping[str, object], - reset: Callable[[str], dict[str, object]], - ) -> dict[str, object]: - """Record, and warn about, an Adams resume whose file holds no history.""" - kept, why = retention.get("state"), retention.get("reason") - if kept == "omitted" and why == "automatic_size_limit": - size = retention.get("nbytes") - limit = retention.get("limit") - warnings.warn( - "the Adams history starts afresh: the run did not keep it for a " - "resume, " - + ("" if size is None else f"{size} bytes being ") - + "over the " - + ("automatic limit" if limit is None else f"{limit} bytes") - + " kept by default (resume_history=True keeps it)", - SolpropWarning, - stacklevel=3, - ) - return reset("automatic_size_limit") - lost: tuple[str, str] | None = None - if kept == "retained": - lost = ("kept it here, but it cannot be read back", "unavailable_history") - elif kept == "omitted" and why == "write_failed": - lost = ("was keeping it here when the write failed", "unavailable_history") - elif kept == "omitted" and why == "incompatible_representation": - lost = ( - "could not keep it: the run's engine does not export it", - "incompatible_representation", - ) - if lost is not None: - warnings.warn( - f"the Adams history starts afresh: the file {lost[0]}", - SolpropWarning, - stacklevel=3, - ) - return reset(lost[1]) - if kept == "omitted" and why == "explicit_policy": - return reset("explicit_policy") - # Nothing to continue (a position the march held no history at), or a file - # that does not say: as before, the history starts afresh without a warning. - return reset("unavailable_history") + return _redrawable(simulation, draw, seed) @classmethod @_one_material_context - def from_radial( + def from_capillary( cls, - grid: TimeGrid, - space: RadialGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, + radius: float | ZProfile, + length: float, + gas: Gas | str | Mapping[str, float] | Mixture, + pressure: Pressure | None = None, *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - materials: MaterialSelection | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, - ) -> "Simulation": - """Build a radial propagation using physical fields and the scalar QDHT. - - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope time grid, in SI units. - space : solprop.grid.RadialGrid - Radial nodes, outer radius and spatial collar. - medium : solprop.spatial.SpatialMedium - Homogeneous transverse material; gas density may vary along z. - responses : collections.abc.Sequence - Existing Kerr, plasma or explicitly declared Raman response objects. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Hankel spectrum in V m, with shape - ``(n_r, n_pol, n_omega)``. - settings : SimulationSettings - Step tolerances, positive save positions in m, statistics and backend. - Absolute tolerance has the input spectrum's units, V m. - frame : solprop.linear.Frame or None, optional - Moving frame. None uses the declared responses' THG choice. - noise : {'default', None, 'input', 'modified'}, optional - One photon per transverse column, polarisation and active bin. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Random generator seed; None draws and records system entropy. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the medium's gas from optmat under this selection: a - :class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the - one-population :class:`~solprop.physdata.mixtures.Mixture` of that gas - under the selection at the same pressure and temperature (a supplied - susceptibility is kept). A mixture, glass or constant index is taken as - it is. An optmat rate's energy per event is bound to the input's peak - field (SEP 3, Q14); every response, a - :class:`~solprop.nonlinear.responses.KerrResponse` included, is the - caller's (``gamma3("Ar", wavelength=...)`` is optmat's coefficient at a - carrier). The run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band - mask; the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` - name the default check: the default warns, then stops, when light above - the shot-noise floor reaches them; ``None`` watches nothing. Refused - beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default the safety ramp alone; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. - - Returns - ------- - Simulation - Cached translation and the ordinary resumable execution interface. - - Raises - ------ - DriverError - If a noise seed is given without noise or the input shape is invalid. - """ - materials = MaterialSelection() if materials is None else materials - return cls._from_spatial( - grid, - space, - _selected_medium(medium, materials), - responses, - initial, - settings=_bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, - ), - frame=frame, - noise=noise, - seed=seed, - materials=materials, - ) - - @classmethod - @_one_material_context - def from_free( - cls, - grid: TimeGrid, - space: FreeGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - materials: MaterialSelection | None = None, + pressure_floor: float | None = None, + vacuum_end: VacuumEnd | None = None, + wavelength: float, + wavelength_limits: tuple[float, float] | None = None, + trange: float | None = None, + grid: TimeGrid | None = None, + envelope: bool = False, + fwhm: float | None = None, + width: float | None = None, + shape: str = "gauss", + energy: float | None = None, + peak_power: float | None = None, + phases: Sequence[float] | np.ndarray = (), + pulses: Sequence[Pulse] | None = None, + launch: ModalLaunch | None = None, + modes: int | Sequence[Mode] = 1, + mode_average: bool = False, + lattice: TubeLattice | None = None, + wall: CoherentWall | None = None, + taper_coupling: bool = True, + model: MarcatiliModel = "full", + loss: LossSpec = True, + cladding: GlassIndex | complex | None = None, + susceptibility: GasSusceptibility | None = None, + temperature: float | ZProfile | None = None, + kerr: bool = True, + thg: bool | None = None, + plasma: bool + | IonisationRate + | Mapping[str, bool | IonisationRate] + | None = None, + preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None, + plasma_integration: IntegrationPolicy | None = None, + raman: bool + | Sequence[str] + | Mapping[str, bool | MolecularOptions] + | None = None, + n_r: int | None = None, + n_theta: int | None = None, + radial_rule: RadialRule = "gauss_kronrod", + ionisation_field_range: tuple[float, float] | None = None, absorber: Absorber | Literal[False] | None = None, absorber_scheme: AbsorberScheme | None = None, boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + rtol: float | None = None, + atol: float = WAVEGUIDE_ATOL, + dz_max: float | None = None, + dz_init: float | None = None, + schedule: FixedSchedule | None = None, + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, + interpolate_planes: bool = False, + step_planes: int | None = None, + save_planes: Sequence[float] | np.ndarray | None = None, + n_save: int | None = None, + stats: StatsSpec | None = None, + polarisation_taper: bool = False, + node_budget: int | None = None, + backend: str = "auto", + polarisation: str | float | None = None, + materials: MaterialSelection | None = None, + tdse: TDSEGasResponse | None = None, + tdse_execution: TDSEExecution | None = None, ) -> "Simulation": - """Build a Cartesian propagation with constant media and transverse FFTs. + """Build a simulation of a pulse in a gas-filled hollow waveguide. - A :class:`solprop.grid.LineGrid` runs the same transform with one y column: - the beam is uniform in y, the run records ``transverse_measure = - "per_unit_width"``, and its energies and powers are per metre of y. + The constructor :func:`solprop.driver.interface.prop_capillary` calls. The mode + set is the first `modes` HE_1m modes of the capillary unless a set is given; the + transverse integral is a fixed quadrature rule + (``docs/guide/waveguides/mode-sets.md``); the + gas density along z is the pressure path through the equation of state, and the + same path sets the modes' core index, so a gradient fill disperses and broadens + consistently. + + See ``docs/guide/setup/running.md`` for the discussion and a worked example. Parameters ---------- - grid : solprop.grid.TimeGrid - Field or envelope time grid, in SI units. - space : solprop.grid.FreeGrid - Cartesian axes, area measure and spatial collar, or a - :class:`solprop.grid.LineGrid`. - medium : solprop.spatial.SpatialMedium - Constant bulk material; varying profiles are refused. - responses : collections.abc.Sequence - Existing Kerr, plasma or explicitly declared Raman response objects. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Cartesian DFT spectrum in V/m, with shape - ``(n_x*n_y, n_pol, n_omega)``. - settings : SimulationSettings - Step tolerances, positive save positions in m, statistics and backend. - Absolute tolerance has the input spectrum's units, V/m. - frame : solprop.linear.Frame or None, optional - Moving frame. None uses the declared responses' THG choice. - noise : {'default', None, 'input', 'modified'}, optional - One photon per transverse column, polarisation and active bin. - ``"default"``, the default, resolves to no noise on a Cartesian or line - grid and records why (:data:`~solprop.driver.defaults.FREE_NOISE`, SEP - 37); pass ``"input"`` or ``"modified"`` for a noise model. - seed : int or None, optional - Random generator seed; None draws and records system entropy. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the medium's gas from optmat under this selection: a - :class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the - one-population :class:`~solprop.physdata.mixtures.Mixture` of that gas - under the selection at the same pressure and temperature (a supplied - susceptibility is kept). A mixture, glass or constant index is taken as - it is. An optmat rate's energy per event is bound to the input's peak - field (SEP 3, Q14); every response, a - :class:`~solprop.nonlinear.responses.KerrResponse` included, is the - caller's (``gamma3("Ar", wavelength=...)`` is optmat's coefficient at a - carrier). The run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band - mask; the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` - name the default check: the default warns, then stops, when light above - the shot-noise floor reaches them; ``None`` watches nothing. Refused - beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default the safety ramp alone; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. - - Returns - ------- - Simulation - Cached translation and the ordinary resumable execution interface. - - Raises - ------ - DriverError - If a noise seed is given without noise or the input shape is invalid. - """ - materials = MaterialSelection() if materials is None else materials - medium = _selected_medium(medium, materials) - if not medium.density.is_constant: - msg = ( - "Cartesian free propagation requires constant media; " - "varying density profiles are unsupported" - ) - raise DriverError(msg) - simulation = cls._from_spatial( - grid, - space, - medium, - responses, - initial, - settings=_bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, - ), - frame=frame, - noise=noise, - seed=seed, - materials=materials, - ) - if isinstance(space, LineGrid): - # The run's powers and energies are per metre of y; processing and - # plotting label them from this key, as for a slab. - simulation.configuration["transverse_measure"] = space.transverse_measure - return simulation - - @classmethod - @_one_material_context - def from_planar( - cls, - grid: TimeGrid, - space: PlanarGrid, - responses: Sequence[object], - initial: PlanarPulse | Pulse | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - materials: MaterialSelection | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, - ) -> "Simulation": - """Build a hybrid planar propagation: slab modes in x, an FFT in y. - - The hollow planar guide resolved in both transverse directions: the slab modes - of `space` in x and its periodic y axis, exact (non-paraxial) in y, so - whole-beam self-focusing and the modulational instability in y -- which a slab - uniform in y cannot show -- are in the model. The fill is the modes' own, as a - bulk medium; it and the gap must be constant along z. The geometry integrates - over y, so energies are in J and powers in W. The collar in y absorbs only when - `settings` declares an absorber, as for :meth:`from_free`. - - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope time grid, in SI units. - space : solprop.modes.slab.PlanarGrid - The slab modes (one gap, one polarisation family, one constant fill) and - the y axis with its collar. - responses : collections.abc.Sequence - Existing Kerr, plasma or explicitly declared Raman response objects, - scaled by the fill's number density. - initial : solprop.pulses.PlanarPulse or numpy.ndarray - The astigmatic launch, or an internal ``(p, k_y)`` spectrum of slab - amplitudes in sqrt(W/m), shape ``(n_p*n_y, 1, n_omega)``, the mode - index fastest. Any other pulse is refused by name. - settings : SimulationSettings - Step tolerances, positive save positions in m, statistics and backend. - Absolute tolerance has the input spectrum's units, sqrt(W/m). - frame : solprop.linear.Frame or None, optional - Moving frame. None uses the declared responses' THG choice, following - the first mode at ``k_y = 0`` as the modal route does. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the slab modes' built-in materials from optmat under this selection - and record it in the run's manifest: a fill of a - built-in gas with its default susceptibility takes the selection's - single-species susceptibility (no mixture is needed), and a slab without - a declared cladding takes optmat's recommended fused silica (SEP 3, Q5). - A supplied susceptibility or cladding is taken as it is. An optmat - rate's energy per event is bound to the input's peak field (SEP 3, Q14), - and the input is checked against each rate. ``None`` (the default) is - ``MaterialSelection()``. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band - mask; the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` - name the default check: the default warns, then stops, when light above - the shot-noise floor reaches them; ``None`` watches nothing. Refused - beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default the safety ramp alone; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. - - Returns - ------- - Simulation - Cached translation and the ordinary resumable execution interface. - - Raises - ------ - DriverError - If the fill varies along z or is a mixture, the input is neither a - :class:`solprop.pulses.PlanarPulse` nor a spectrum of the planar shape. - """ - materials = MaterialSelection() if materials is None else materials - space = _selected_planar(space, materials) - medium = _planar_medium(space) - if not isinstance(initial, PlanarPulse | np.ndarray): - msg = ( - "a planar run launches a PlanarPulse (waist_x projected onto the slab " - "modes, waist in y) or takes a built (p, k_y) spectrum; got " - f"{type(initial).__name__}" - ) - raise DriverError(msg) - chosen_frame = frame - if chosen_frame is None: - chosen_frame = Frame.comoving( - grid, thg=declares_harmonic_carrier(responses, grid) - ) - if isinstance(initial, np.ndarray): - # Refused before the simulation copies it. - _check_planar_input(initial.shape, space.n_columns, grid.n_omega) - settings = _spatial_default( - _bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, - ), - grid, - ) - simulation = cls( - grid, - (), - responses, - initial, - settings=_spatial_settings(settings, "planar"), - noise_resolution=NoiseResolution("default", None, PLANAR_NOISE), - profiles={"density": medium.density}, - spatial_grid=space, - medium=medium, - frame=chosen_frame, - materials=materials, - ) - _check_planar_input(simulation._field0.shape, space.n_columns, grid.n_omega) - _bind_spatial_rates(simulation, initial) - return simulation - - @classmethod - def _from_spatial( - cls, - grid: TimeGrid, - space: RadialGrid | FreeGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - materials: MaterialSelection | None = None, - ) -> "Simulation": - """Build a radial or Cartesian propagation using physical fields. - - - ``space``: radial nodes, outer radius and spatial collar, or the Cartesian - axes and their collar. - - ``initial``: beam specification or internal spectrum, a Hankel spectrum in V m - or a raw Cartesian DFT spectrum in V/m, with shape ``(n_columns, n_pol, - n_omega)``. - - ``settings``: step tolerances, positive save positions in m, statistics and - backend. Absolute tolerance has the input spectrum's units. - - ``frame``: moving frame. None uses the declared responses' THG choice. - - ``seed``: random generator seed; None draws and records system entropy. - - Returns: cached translation and the ordinary resumable execution interface. - """ - chosen_frame = frame - if chosen_frame is None: - chosen_frame = Frame.comoving( - grid, thg=declares_harmonic_carrier(responses, grid) - ) - if isinstance(medium.material, Mixture) and settings.stats is None: - base = default_spec("radial" if isinstance(space, RadialGrid) else "free") - settings = dataclasses.replace( - settings, - stats=dataclasses.replace( - base, - names=( - *base.names, - "species_ionisation", - "species_electron_density", - "species_peak_ionisation_rate", - "species_density", - "species_ionisation_energy", - ), - ), - ) - settings = _spatial_default(settings, grid) - if isinstance(initial, np.ndarray): - # Refused before the simulation copies it. - _check_spatial_input(initial.shape, space.n_columns, grid.n_omega) - simulation = cls( - grid, - (), - responses, - initial, - settings=_spatial_settings( - settings, "radial" if isinstance(space, RadialGrid) else "free" - ), - profiles=mixture_profiles(medium.material) - if isinstance(medium.material, Mixture) - else {"density": medium.density}, - spatial_grid=space, - medium=medium, - frame=chosen_frame, - materials=materials, - ) - _check_spatial_input(simulation._field0.shape, space.n_columns, grid.n_omega) - noise_model, resolution = resolve_noise( - "radial" if isinstance(space, RadialGrid) else "free", - noise, - seed, - ) - simulation.noise_resolution = resolution - if noise_model is not None: - draw = _spatial_draw(noise_model, simulation, medium) - simulation.noise = draw(seed) - _redrawable(simulation, draw, seed) - _bind_spatial_rates(simulation, initial) - return simulation - - @classmethod - @_one_material_context - def from_gnlse( - cls, - gamma: float | None, - length: float, - betas: Sequence[float] | np.ndarray | None = None, - *, - wavelength: float, - wavelength_limits: tuple[float, float] | None = None, - trange: float | None = None, - grid: TimeGrid | None = None, - beta: tuple[Float[np.ndarray, " n_nodes"], Float[np.ndarray, " n_beta_nodes"]] - | Callable[[Float[np.ndarray, " n_omega"]], Float[np.ndarray, " n_omega"]] - | None = None, - fibre: Mode | IndexTable | None = None, - n2: float | str | None = None, - fwhm: float | None = None, - width: float | None = None, - shape: str = "gauss", - energy: float | None = None, - peak_power: float | None = None, - phases: Sequence[float] | np.ndarray = (), - pulses: Sequence[Pulse] | None = None, - loss_db_per_m: float | None = None, - shock: bool = True, - raman: bool = False, - raman_fraction: float | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - rtol: float | None = None, - atol: float = DEFAULT_ATOL, - dz_max: float | None = None, - dz_init: float | None = None, - schedule: FixedSchedule | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - interpolate_planes: bool = False, - step_planes: int | None = None, - save_planes: Sequence[float] | np.ndarray | None = None, - n_save: int | None = None, - stats: StatsSpec | None = None, - polarisation_taper: bool = False, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - backend: str = "auto", - materials: MaterialSelection | None = None, - ) -> "Simulation": - """Build a simulation of a GNLSE fibre from its coefficients. - - The constructor :func:`solprop.driver.interface.prop_gnlse` calls; its docstring - documents every keyword, and this method takes them unchanged. The GNLSE is not - a separate solver path: it is the mode-averaged envelope problem with a - dispersion-specified mode, ``density = 1``, ``prefactor_index = 1`` and the - shock term supplied by the prefactor's ``omega`` dependence - (``docs/guide/waveguides/gnlse.md``). - - Parameters - ---------- - gamma : float or None - Nonlinear coefficient in 1/(W m). + radius : float or solprop.profiles.ZProfile + Core radius, m, constant or along z. length : float - Fibre length, m, positive. - betas : array_like, optional - Taylor coefficients of ``beta`` about `wavelength`, ``beta_0, beta_1, ...`` - in s^n/m. Give exactly one of this, `beta` or `fibre`. - wavelength : float - Reference wavelength, m: the ``gamma`` mapping's, the Taylor expansion's, - the grid's and the pulse's centre. - wavelength_limits : tuple of float, optional - Shortest and longest wavelength of the frequency window, m. Required unless - `grid` is given. - trange : float, optional - Extent of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid, optional - An envelope grid to use instead of building one. A field grid is refused. - beta : tuple or callable, optional - The propagation constant as data: a pair of arrays ``(omega, beta)`` in - rad/s and 1/m, or a callable of ``omega`` sampled onto the grid's active - band. Both go through :class:`solprop.nonlinear.gnlse.TabulatedDispersion`. - fibre : Mode or IndexTable, optional - Whole dispersion and loss instead of `betas` or `beta`. A mode goes - through :meth:`solprop.nonlinear.gnlse.SpecifiedMode.from_mode`, so one - whose geometry moves along z -- a tapered mode, a moving guide's - provider channel, a ``SpecifiedMode`` with dispersion and area paths -- - hands over its dispersion and its area along z; only the fixed-step - scaffold requires a z-constant fibre. Its fill is ignored as a - nonlinear material: the GNLSE uses gamma or n2, retaining the mode only - for dispersion, loss and area. A table's area is evaluated at - `wavelength`, and so is a provider channel's, by a fresh solve there (along - the path of a moving guide); without an area column a table requires - `gamma` and uses the 1 m^2 convention. - n2 : float or str, optional - Nonlinear index in m^2/W, requiring `fibre` and ``gamma=None``. - Exactly one of `gamma` and `n2` is required. The mapping is - ``gamma = n2 omega0 / (c A_eff)``, with unit prefactor index. Under - `materials`, ``"recommended"`` takes optmat's silica bundle's total - ``n2`` at `wavelength`. - fwhm : float, optional - Intensity full width at half maximum of the input, s. - width : float, optional - Natural width of a ``sech**2`` input, s. Give this or `fwhm`. + Capillary length, m. + gas : Gas, str, Mapping or Mixture + Built-in gas, mole fractions keyed by species name, or ordered populations + carrying their own density paths. A mapping uses total pressure. + pressure : float, sequence, ZProfile or None, optional + None uses one bar for a gas or fractions. Omit pressure beside a Mixture; + every explicit pressure is refused because it already carries its paths. + Fill pressure in **bar**: a number for a constant fill, a pair + ``(p_in, p_out)`` for the steady-flow gradient of + :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the + positions and the pressures there for + :meth:`solprop.profiles.PressureGradient.from_stages`. + pressure_floor : float or None, optional + Lowest pressure the fill is allowed to reach, bar; ``None`` takes + :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest + stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true + vacuum end, which warns, because ``dp/dz`` diverges there. + Refused beside a constant pressure or a pressure profile, which have + no end to apply it to. + vacuum_end : {'choked', 'floor'} or None, optional + How a pressure gradient's outer end below its choked pressure is treated. + ``None`` (the default) means ``"choked"`` for a circular hollow guide: the + fill follows the bore (Pennetta et al. 2019, eq. 6) and an end exhausting + into vacuum takes the choked pressure of the flow + (:func:`solprop.gasflow.steady_fill`), with a warning if the flow is not + laminar. ``"floor"`` concerns the ends only: it keeps the declared end + pressures, raised only to `pressure_floor`, for a comparison with a model + that imposes them, while a taper's fill still follows the bore (on a + uniform bore, bitwise the uniform law). Refused beside a constant + pressure, a pressure profile or a Mixture, which have no end to choke, + and on the general route. Every mode declaring ``core_radius`` -- every + ``MarcatiliMode`` subclass, a ``TabulatedMode`` built from arrays + included -- is taken as a circular bore of that radius; a rectangular + guide keeps the uniform law. + wavelength : float + Reference wavelength, m, and the pulse's centre wavelength. + wavelength_limits : tuple of float or None, optional + Shortest and longest wavelength of the frequency window, m. Required unless + `grid` is given. + trange : float or None, optional + Requested width of the time window, s. Required unless `grid` is given. + grid : solprop.grid.TimeGrid or None, optional + A grid of one's own, instead of the three keywords above; the only way to + reach ``dt``, ``ffac`` or the transform size. + envelope : bool, optional + Propagate an envelope. Plasma is off by default; explicit ``plasma=True`` + selects cycle averaging of instantaneous rates. Plasma forbids THG. + fwhm : float or None, optional + Pulse duration at half maximum, s. + width : float or None, optional + Natural width of a sech pulse, s; give this or `fwhm`. shape : {'gauss', 'sech'}, optional - Shape of the input. - energy : float, optional + Transform-limited shape of the input. + energy : float or None, optional Pulse energy, J. Give this or `peak_power`. - peak_power : float, optional - Peak power, W. - phases : array_like, optional - Optics-convention spectral phase about `wavelength`: CEP in rad, then group - delay, GDD, TOD, ... in s^n. + peak_power : float or None, optional + Peak power, W, realised after the spectral phases. + phases : sequence of float, optional + Taylor spectral phases about `wavelength`, optics convention: CEP in rad, + group delay in s, GDD in s^2, and so on. pulses : sequence of solprop.pulses.Pulse or None, optional Several pulses launched together, for two-colour and multi-colour inputs: each a :mod:`solprop.pulses` object with its own wavelength, @@ -4317,43 +2820,179 @@ def from_gnlse( single-pulse keywords `fwhm`, `width`, `shape`, `energy`, `peak_power` and `phases`, which must then be left unset; `wavelength` still sets the grid's reference wavelength. - loss_db_per_m : float or None, optional - Omitted means zero loss on the coefficient routes. Refused beside - `fibre`, which carries its own loss. Power loss in dB/m, converted by - :func:`solprop.nonlinear.gnlse.attenuation_from_db`. - shock : bool, optional - Keep the prefactor's ``omega`` dependence, which is the shock term. - raman : bool, optional - Whether a fused-silica Raman response takes `raman_fraction` of ``gamma``. - raman_fraction : float or None, optional - Share of ``gamma`` held back from the Kerr term, between 0 and 1. Omitted, - the silica bundle's ``f_R`` with `raman` true and 0 with it false; given, - held back either way. + launch : mapping, sequence of mapping, or None, optional + Launch the input into chosen modes (SEP 41, S5): a mapping from a mode's + display label or index to its share, or to ``{"share": s, "phase": phi}`` + with the phase in radians (optics convention), as + ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, "phase": 1.57}}``; or a list of + such mappings, one for each pulse in `pulses`. A label may leave out a + parity the set carries once (``"HE1,1"`` for ``"HE1,1:even"``). The shares + are energy fractions, normalised to one for each pulse, and the launch is + recorded as ``configuration["modal_launch"]`` + (``docs/guide/setup/pulses.md``). ``None``, the default, puts the input in + the first mode. + modes : int or sequence of Mode, optional + How many HE_1m modes to propagate, or the mode set itself. A set is checked + for orthonormality. Supplied sets may include TabulatedHollowMode or + TabulatedMode with their own tables. A supplied set **carries its own + geometry**: `radius` must agree with every mode's core radius along the run, + and a `model`, `loss` or `cladding` declared away from its default must be + every mode's own, or the call is refused by name rather than the argument + ignored. + mode_average : bool, optional + Propagate the one mode **mode-averaged**, as a uniform disc of its + effective area, instead of resolving it on the radial rule + (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the + area-mean field, below the on-axis one (``HE_11``'s axis intensity is + 1.768 times ``P/A_eff``), and ``peak_intensity`` stays on the axis. + ``False``, the default, resolves the mode. Refused beside more than one + mode and beside `n_r`, `n_theta` or a `radial_rule`. + lattice : TubeLattice or None, optional + Tube geometry for an integer mode count, selecting Vincetti modes. + Refused with supplied modes, which carry their own geometry, and with + ``model="reduced"``. None selects ordinary capillary modes. + wall : solprop.modes.CoherentWall or None, optional + Declare the scalar **coherent** wall block: the outgoing Robin boundary + condition condensed onto the retained basis, which couples every ``HE_1m`` + channel to every other with a definite phase instead of giving each its own + Marcatili attenuation. It needs the contiguous set ``HE_11`` to ``HE_1N`` -- + or, with ``impedance="te_tm"``, one azimuthal order -- on a constant or a + varying guide, and a scalar run; it carries the default ``"input"`` noise + and refuses ``"modified"``. ``numpy_ref`` and ``native`` execute it; + every other mode set or geometry is refused by name. ``None``, the + default, carries no wall: the modes keep their own Marcatili loss, and a + multimode taper its taper coupling (below). + taper_coupling : bool, optional + On a multimode capillary whose radius varies along z, carry the + **taper coupling** -- the connection ```` that moves + power between the ``HE_1m`` channels as the bore changes -- lossless or + not. ``True``, the default; ``False`` is the explicit opt-out to the + **adiabatic** route, on which each channel follows its local mode and + exchanges no power. It is refused beside `wall`, which always carries + the connection, and has no effect on one channel, a constant bore (a + pressure gradient included, which does not change the modes' shape) or + a set that is not the contiguous ``HE_11`` to ``HE_1N`` block + (``docs/guide/waveguides/capillaries.md``, "Tapers"). + model : {'full', 'reduced'}, optional + Which Marcatili model the modes use; beside a supplied set, refused unless + it is the default or the modes' own. + loss : bool, float or callable, optional + Propagation loss of the modes; beside a supplied set, refused unless it + is the default or the modes' own. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is the package's cited silica. Beside a + supplied set, refused unless it is the default or the modes' own. + susceptibility : GasSusceptibility or None, optional + A gas susceptibility replacing the one `gas` selects, for a comparison + against another code's refractive data. Refused for a fraction mapping + or an existing mixture, which own their species models. + temperature : float, ZProfile or None, optional + Gas temperature, K, or a path of it along z in m. Sets the density the + pressure path maps to, so it changes both the dispersion and the + nonlinearity. A path also weights a steady-flow gradient by the gas's + ``eta(T) T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's + populations independent parameters, and holds the rotational Raman + populations at one reference temperature with a + :class:`~solprop.exceptions.RamanTemperatureWarning` + (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path + is the number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture + owns its temperature: beside one, a temperature must be omitted or be the + mixture's own (the same number or path object), and any other is refused by + name. + kerr : bool, optional + Include the Kerr response. + thg : bool or None, optional + Include third-harmonic generation in the Kerr term; ``None`` takes + ``not envelope``. + plasma : bool, IonisationRate, Mapping or None, optional + Include photoionisation. ``None`` takes ``not envelope``. ``True`` selects + optmat's recommended rate model of each built-in gas (SEP 3, D1; + :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the + selection's ``ionisation`` names), for a single gas and for every + component of a mixture; a custom species needs its own rate. A + rate object is used as given; a mapping selects a rate or switch per + component label, and omitted labels are off. + preionisation : float, ZProfile, Mapping or None, optional + Initial ionised fraction in [0, 1], globally or per label: a number, or a + path along z in m giving the fraction ionised before the pulse arrives at + each position. None and omitted mapping labels retain the component's + existing declaration. + plasma_integration : {'band', 'trapezoid'} or None, optional + How the field-grid plasma forms its population and free-current integrals + (SEP 18): ``'band'`` filters each integrand with taps designed for the + grid, exact to a certified 1e-5 across the active band; ``'trapezoid'`` + is the plain cumulative trapezoid, short by up to 11 % per integral at + the top of the default active band, which the frozen witness comparisons + use. ``None`` takes :class:`~solprop.nonlinear.responses.PlasmaResponse`'s + default. Refused with envelope plasma, whose currents are converted + exactly, and when the plasma is off. + raman : bool, sequence of str, Mapping or None, optional + ``None`` (the default) turns the Raman response on for every species + whose optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- leaves + an atom without one, and warns (:class:`solprop.exceptions.SolpropWarning`) + for a molecule whose response is provisional (N2O, unless the selection's + ``raman`` is ``"provisional"``) rather than leaving it out silently; with + ``kerr=False`` the default is off. ``False`` disables it; ``True`` selects + the available models of a mixture and is refused by name for a single gas + without data; explicit labels or + :class:`~solprop.physdata.raman_types.MolecularOptions` select components + and terms, and a named unavailable model fails explicitly. + n_r : int or None, optional + Radial nodes of the fixed rule. ``None`` takes + :func:`~solprop.driver.routes.hollow.default_radial_order` of the mode + count. + n_theta : int or None, optional + Azimuthal nodes. Refused for a set of purely radial modes, which need none. + radial_rule : str, optional + The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose + embedded Gauss subset is the quadrature alarm's error estimate; + ``"gauss"``, with no alarm; or ``"field_scale"`` and + ``"field_scale_radau"``, Gaussian in the HE_11 field scale for expensive + responses, which need exactly one Marcatili HE_11 mode and have no alarm + (``docs/guide/waveguides/radial-quadrature.md``). + ionisation_field_range : tuple of float or None, optional + Field amplitudes in V/m the rate table must cover. ``None`` derives them + from the input's peak field over the axis and the transverse nodes; see + :func:`solprop.driver.inputs.default_field_range`. + absorber : solprop.boundaries.Absorber, False or None, optional + The collar rates; ``None`` builds the default collar of the grid, and + ``False`` switches both soft collars off and keeps the hard band mask + (:func:`solprop.boundaries.no_absorber`; SEP 21). + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + How the temporal collar is applied (ADR-025): inside every stage, the + default, or once per accepted step. The low-call Adams methods of + SEP 13 step much further with ``'split_step'``, whose first-order + splitting error stays inside the collar. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges (SEP 21): the default warns, + then stops, when light above the shot-noise floor reaches them; ``None`` + watches nothing, for a window kept narrow on purpose. noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry (``docs/guide/media/noise.md``). + Which shot-noise model to carry. ``"default"``, the default, resolves to ``"input"`` wherever the run can carry noise, and records the resolution (SEP 37). - seed : int, optional - Entropy of the generator the realisation is drawn from, recorded in the - output so a run is reproducible. + seed : int or None, optional + Entropy of the noise generator. rtol : float or None, optional - Relative tolerance of the step controller. + Relative step tolerance; the default is + :data:`~solprop.driver.defaults.CAPILLARY_RTOL`, the capillary default. + Calibrate it for a production run (``docs/dev/stepper.md`` section 3). atol : float, optional - Absolute tolerance, in the square root of watts. - dz_max : float, optional - Cap on the controller's proposal, m; ``None`` uses half the length. - dz_init : float, optional - First step, m; ``None`` uses a ten-thousandth of the length. - schedule : solprop.stepper.schedule.FixedSchedule, optional - A fixed schedule to replay instead of stepping adaptively - (``docs/dev/stepper.md`` section 8). + Absolute step tolerance, in the square root of watts. + dz_max : float or None, optional + Cap on the step, m; ``None`` takes half the capillary. + dz_init : float or None, optional + First step, m; ``None`` takes ``length * 1e-4``. + schedule : solprop.stepper.schedule.FixedSchedule or None, optional + A fixed schedule to replay instead of stepping adaptively. integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture - DOPRI5 march, which SEP 37 measured fastest at matched accuracy on the - GNLSE. ``Multistep()`` is the low-call exponential Adams method, one - response evaluation per step. ``LowStorageRungeKutta()`` replays + ``None``, the default, takes ``Multistep()``: the low-call exponential + Adams method, one response evaluation per step. ``RungeKutta()`` is the + interaction-picture DOPRI5 march, for a cheap response or a state too + large for the Adams history, and faster on a long run with Raman or + plasma (SEP 37). ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). interpolate_planes : bool, optional Observe the save planes inside accepted steps instead of landing on them @@ -4361,153 +3000,165 @@ def from_gnlse( continuous extension with ``RungeKutta()``. Default ``False``. step_planes : int or None, optional Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); - ``None``, the default, and ``0`` save the requested planes only. - save_planes : array_like, optional + march's own positions, beside the requested planes (SEP 14); ``0`` + saves the requested planes only. ``None``, the default, is ``0``, or + ``1`` with a `tdse` response, which saves every accepted step without + imposing a landing (SEP 20 section 5.8). + save_planes : sequence of float or None, optional Positions to save at, m. Give this or `n_save`. - n_save : int, optional - Number of planes to store, counting the one at ``z = 0``, spread evenly over - the fibre. Default 201. - stats : solprop.stats.StatsSpec, optional - Which statistics to record; ``None`` records - :func:`solprop.stats.default_spec` of a mode-averaged run. + n_save : int or None, optional + How many planes to save, counting ``z = 0``. + stats : solprop.stats.StatsSpec or None, optional + Which statistics to record; ``None`` takes + :func:`solprop.stats.default_spec` for a multimode run with the profiles, + or for a mode-averaged one under `mode_average`, plus every statistic + only a backend can compute. polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation. Default - ``False``: the taper costs a systematic error of the size of the field's - content in the collar, and the rate absorber acts either way. - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid - (:func:`default_collar`), and ``False`` switches both soft collars off - and keeps the hard band mask (:func:`solprop.boundaries.no_absorber`). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How a temporal collar is applied (ADR-025); ``None`` is the default, - inside every stage. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, - then stops, when light above the shot-noise floor reaches them; ``None`` - watches nothing, for a window kept narrow on purpose. + Whether the spectral collar tapers the nonlinear polarisation as well as + absorbing the field through the linear operator. Default ``False``: the + taper costs a systematic error of the size of the field's content in the + collar, and the rate absorber acts either way + (``docs/guide/numerics/boundaries.md``). + node_budget : int or None, optional + Bytes of node-chunk buffers the response pass may hold, an execution + setting; ``None``, the default, leaves the backend's automatic choice. backend : str, optional Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it isn't installed or this processor is below its baseline. There is no fallback, and import or construction errors stay visible. + polarisation : str, float or None, optional + Refused; this path is scalar. materials : solprop.physdata.selection.MaterialSelection or None, optional - Take fused silica's nonlinear response from optmat's silica bundle - (:class:`~solprop.physdata.glasses.SilicaBundle`): with `raman` on, an - omitted fraction is the bundle's ``f_R`` and the response its sampled - shape; ``n2="recommended"`` is its total ``n2``. The fibre's dispersion, - area and a given ``gamma`` or ``n2`` are the caller's. The run records - its manifest, the held-back fraction and its origin - included. ``None`` (the default) is ``MaterialSelection()``. + Take the run's built-in materials from optmat under this selection: a gas or + fraction mapping of built-in gases, ``"air"`` and ``Gas.Air`` resolve + through :func:`solprop.physdata.species` and + :meth:`solprop.physdata.mixtures.Mixture.air` with it, and the default + cladding (``cladding=None`` with a numbered mode set) is optmat's + recommended silica. A supplied mixture, mode set, cladding or susceptibility + is taken as it is. The run records its material manifest. + ``None`` (the default) is ``MaterialSelection()``. + + tdse : solprop.nonlinear.tdse.TDSEGasResponse or None, optional + A helium TDSE response, SAE or haCC, used as the complete electronic + response of the fill instead of the Kerr, plasma and Raman responses + (SEP 20 section 5.2); ``plasma``, ``raman``, ``preionisation``, + ``plasma_integration``, ``ionisation_field_range`` and ``thg`` must then + be left unset and ``kerr`` true. ``backend="auto"`` resolves to + ``"numpy_ref"``, the only backend that executes it; statistics default to + those the route defines; omitted `save_planes` and `n_save` save only the + final position beside every accepted step. An atomic window whose duration + is unresolved takes `trange`. ``None`` keeps the rate-based responses. + tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None, optional + Atomic runtime resources of `tdse`: device, memory limit, rows per call + and call cap. ``None`` beside `tdse` takes ``TDSEExecution()``; refused + without `tdse`. Returns ------- Simulation - Ready to :meth:`run`. + The simulation, ready to translate and run. Raises ------ DriverError - If the fibre, the grid, the input or the save planes are specified in a way - this interface does not accept; the message names what is wrong. + If a keyword is missing, inconsistent, or names something this path does + not carry. Examples -------- >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + >>> simulation = Simulation.from_capillary( + ... 125e-6, 0.1, "He", (0.8, 0.1), wavelength=800e-9, + ... wavelength_limits=(150e-9, 2e-6), trange=200e-15, + ... fwhm=10e-15, energy=100e-6, modes=2, n_save=3, ... ) - >>> simulation.settings.save_planes.tolist() - [0.005, 0.01] + >>> len(simulation.modes), simulation.settings.quadrature.n_nodes + (2, 33) + >>> sorted(simulation.profiles) + ['core_radius', 'density', 'pressure'] """ - materials = MaterialSelection() if materials is None else materials - _refuse_dispersive_policy(materials, "a GNLSE run's fitted gamma") - working = _gnlse_grid( - grid, - length, - wavelength, - wavelength_limits, - trange, - ) - _check_selection_carrier(materials, working.reference_wavelength, wavelength) - mapping = _gnlse_fibre( - gamma, - betas, - beta, - working, - wavelength=wavelength, - loss=loss_db_per_m, - fibre=fibre, - n2=n2, - shock=shock, - raman=raman, - raman_fraction=raman_fraction, - selection=materials, - ) - pulse = _shaped_pulse( - shape, + return _from_hollow( + cls, + radius=radius, + length=length, + gas=gas, + pressure=pressure, + pressure_floor=pressure_floor, + vacuum_end=vacuum_end, wavelength=wavelength, + wavelength_limits=wavelength_limits, + trange=trange, + grid=grid, + envelope=envelope, fwhm=fwhm, width=width, + shape=shape, energy=energy, peak_power=peak_power, phases=phases, pulses=pulses, - ) - boundary, scheme = _boundary(absorber, absorber_scheme, working) - controller, default = _keyword_controller("gnlse", rtol, atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route="gnlse", - save_planes=_save_planes(length, save_planes, n_save), - # Relative defaults, so that the first step and the cap scale with the - # fibre. - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, + launch=launch, + modes=modes, + mode_average=mode_average, + lattice=lattice, + wall=wall, + taper_coupling=taper_coupling, + model=model, + loss=loss, + cladding=cladding, + susceptibility=susceptibility, + temperature=temperature, + kerr=kerr, + thg=thg, + plasma=plasma, + preionisation=preionisation, + plasma_integration=plasma_integration, + raman=raman, + n_r=n_r, + n_theta=n_theta, + radial_rule=radial_rule, + ionisation_field_range=ionisation_field_range, + absorber=absorber, + absorber_scheme=absorber_scheme, + boundary_check=boundary_check, + noise=noise, + seed=seed, + rtol=rtol, + atol=atol, + dz_max=dz_max, + dz_init=dz_init, schedule=schedule, integrator=integrator, interpolate_planes=interpolate_planes, - step_planes=0 if step_planes is None else step_planes, - density=1.0, - shock=shock, - prefactor_index=mapping.prefactor_index, - stats=default_spec("mode_averaged") if stats is None else stats, - absorber=boundary, - absorber_scheme=scheme, - boundary_check=boundary_check, + step_planes=step_planes, + save_planes=save_planes, + n_save=n_save, + stats=stats, polarisation_taper=polarisation_taper, + node_budget=node_budget, backend=backend, + polarisation=polarisation, + materials=MaterialSelection() if materials is None else materials, + tdse=tdse, + tdse_execution=tdse_execution, ) - noise_model, resolution = resolve_noise("gnlse", noise, seed) - draw = functools.partial(_shot_noise, noise_model, working) - simulation = cls( - working, - mapping.mode, - list(mapping.responses), - pulse, - settings=settings, - noise=draw(seed), - noise_resolution=resolution, - configuration={ - "gnlse": mapping, - "loss_db_per_m": 0.0 if loss_db_per_m is None else loss_db_per_m, - }, - materials=materials, - ) - return _redrawable(simulation, draw, seed) @classmethod @_one_material_context - def from_capillary( + def from_waveguide( cls, - radius: float | ZProfile, + modes: Sequence[Mode], length: float, - gas: Gas | str | Mapping[str, float] | Mixture, + gas: Gas | str | Mapping[str, float] | Mixture | None = None, pressure: Pressure | None = None, *, + provider: ModalGuide | None = None, + channel: int | str | None = None, + incident: SpatialPulse | None = None, + coupling: CouplingModel | None = None, + kerr_form: KerrForm = "real", + observable_rtol: float | None = None, + observable_atol: float | None = None, pressure_floor: float | None = None, vacuum_end: VacuumEnd | None = None, wavelength: float, @@ -4523,14 +3174,6 @@ def from_capillary( phases: Sequence[float] | np.ndarray = (), pulses: Sequence[Pulse] | None = None, launch: ModalLaunch | None = None, - modes: int | Sequence[Mode] = 1, - mode_average: bool = False, - lattice: TubeLattice | None = None, - wall: CoherentWall | None = None, - taper_coupling: bool = True, - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, susceptibility: GasSusceptibility | None = None, temperature: float | ZProfile | None = None, kerr: bool = True, @@ -4545,6 +3188,7 @@ def from_capillary( | Sequence[str] | Mapping[str, bool | MolecularOptions] | None = None, + mode_average: bool = False, n_r: int | None = None, n_theta: int | None = None, radial_rule: RadialRule = "gauss_kronrod", @@ -4569,31 +3213,48 @@ def from_capillary( node_budget: int | None = None, backend: str = "auto", polarisation: str | float | None = None, + taper_coupling: bool = True, materials: MaterialSelection | None = None, - tdse: TDSEGasResponse | None = None, - tdse_execution: TDSEExecution | None = None, ) -> "Simulation": - """Build a simulation of a pulse in a gas-filled hollow waveguide. + """Build a pulse simulation in a waveguide: a gas-filled mode set, or a guide. - The constructor :func:`solprop.driver.interface.prop_capillary` calls. The mode - set is the first `modes` HE_1m modes of the capillary unless a set is given; the - transverse integral is a fixed quadrature rule - (``docs/guide/waveguides/mode-sets.md``); the - gas density along z is the pressure path through the equation of state, and the - same path sets the modes' core index, so a gradient fill disperses and broadens - consistently. + **Without `provider`** this is the gas-filled route: modes keep their + geometry, cladding and loss and receive the declared fill, their nonzero + field components determine the transverse quadrature layout, and rectangular + polarisation partners are separate rows with separate operators. A hollow + planar set (:class:`~solprop.modes.slab.SlabMode`) is uniform in y and runs + **per unit width**: `energy` is J/m, `peak_power` W/m, and the run records + ``transverse_measure = "per_unit_width"``. Step-index fibres use + ``prop_gnlse(fibre=...)`` instead. - See ``docs/guide/setup/running.md`` for the discussion and a worked example. + **With `provider`** this is the **general modal route** + (``docs/guide/waveguides/general-modal.md``): a guide that solves its own + three-component + E and H at quadrature nodes, whose material regions own their responses and + whose channels are Poynting-normalised, so that a channel amplitude is a + true-power amplitude by construction. It is qualified on ``numpy_ref`` and the + native CPU backend, a declared split block's coupled + flow included, and propagates constant geometry on a **field** grid. + Every gas keyword is refused by name beside a provider, because a guide's + materials are its regions' and not a fill. Parameters ---------- - radius : float or solprop.profiles.ZProfile - Core radius, m, constant or along z. + modes : sequence of solprop.modes.protocol.Mode + Non-empty gas-filled mode set with a common transverse geometry. Each + mode is rebound to this constructor's fill (``with_mixture``): its own + gas, pressure and temperature -- a temperature path included -- are + replaced by `gas`, `pressure` and `temperature`, and only its geometry is + kept. Beside + `provider` it must be empty or exactly the guide's own ordered channels: + a guide supplies its channels itself, and the provider has already + validated their order against its fields. length : float - Capillary length, m. - gas : Gas, str, Mapping or Mixture + Waveguide length, m. + gas : Gas, str, Mapping, Mixture or None Built-in gas, mole fractions keyed by species name, or ordered populations - carrying their own density paths. A mapping uses total pressure. + carrying their own density paths. A mapping uses total pressure. Required + without `provider` and refused beside one. pressure : float, sequence, ZProfile or None, optional None uses one bar for a gas or fractions. Omit pressure beside a Mixture; every explicit pressure is refused because it already carries its paths. @@ -4602,6 +3263,67 @@ def from_capillary( :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the positions and the pressures there for :meth:`solprop.profiles.PressureGradient.from_stages`. + provider : solprop.modes.guide.ModalGuide or None, optional + A guide built by + :meth:`solprop.modes.guide.ModalGuide.from_provider`, so that it carries + the sampling its dense E/H reference is rebuilt from. Its knots must + span the run's whole **active** band, which starts at half the lowest + declared frequency; a narrower guide is refused with both bands in the + message. + channel : int or str or None, optional + Which channel the pulse enters, by index or by display label + (``"HE1,1:even"``); ``None`` takes the first, as a gas-filled multimode + run puts a single pulse in its fundamental. The launch is the pulse's + amplitude placed in that channel unchanged -- a Poynting basis needs no + conversion -- so the launched true-power energy is the pulse's. Requires + `provider`. + incident : solprop.pulses.SpatialPulse or None, optional + An **incident beam** to launch instead of a named channel: the beam + and its temporal pulse, whose energy or peak power is met over the + **whole** beam in the paraxial measure of the guide's core medium + (:meth:`solprop.pulses.SpatialPulse.incident`). The beam is coupled into + the channels by :func:`solprop.coupling.coupler.couple` under `coupling` + (SEP 31): it is sampled on a radial plane that resolves it, evaluated at + the guide's nodes, and its coordinates are recovered with the flux + pairing and the forward closure ``H_t = Y0 z x E_t``. The recovered + amplitudes are launched as they are. **Light the channels do not + capture is lost, not restored**: a beam much wider than the core + launches a small share of its energy, and a beam the channels cannot see + launches nothing, which is a valid zero launch. The coupling's + :class:`~solprop.coupling.coupler.CouplingReport` -- incident and launched + energy per frequency and in total, and its admission budget -- is + recorded as ``configuration["launch"]``, and the launch's provenance as + ``configuration["coupling"]``. The beam is launched at its waist + (``propz=0``) and centred on the guide's axis. The pulse keywords + (`fwhm`, `width`, `energy`, `peak_power`, `phases`, `pulses` and + `shape`) are refused beside it, because the beam carries its own + pulse. Requires `provider` and `coupling`, and is refused beside + `channel`. + coupling : solprop.coupling.models.CouplingModel or None, optional + The model an `incident` beam is coupled under: + ``CouplingModel("vector_paraxial", model_error=...)``, whose budget the + guide's contrast, closure and angular content must fit (SEP 31, section + 4.3); a strongly guiding core is refused. Requires `incident`. + kerr_form : {'real', 'analytic'}, optional + Which instantaneous cubic a general modal run's material regions respond + with. ``"real"``, the default, is the real-field cubic + ``P = eps0 chi3 (E.E) E`` the legacy field route evaluates, third harmonic + included; ``"analytic"`` is the analytic-signal form + ``eps0 chi3 (2 |E|^2 E + (E.E) E*) / 4``, which carries the fundamental + band alone and is what a comparison with an envelope route wants. The real + form needs a grid with ``n_to >= 2 n_t``, which is ``FieldGrid``'s default + ``ffac=6``, and is refused on a narrower one. Under one of SEP 6's + dispersive electronic policies a region's gas fill takes the dispersive + cubic in the matching sector: ``"real"`` its full cubic, ``"analytic"`` + its number-conserving sector. Requires `provider`. + observable_rtol, observable_atol : float or None, optional + Requested accuracy of the named physical observables of a general modal run. + ``None``, the only value accepted, means **the solver's own** `rtol` and + `atol`: the route's basis is orthonormal in true power, so the controller's + weighted norm on the amplitudes already is a norm on the physical channel + powers. A different number is refused by name, because the separate error + allocation it would ask for -- over the table's interpolation, the + transverse rule and the step -- is not implemented. Require `provider`. pressure_floor : float or None, optional Lowest pressure the fill is allowed to reach, bar; ``None`` takes :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest @@ -4659,67 +3381,20 @@ def from_capillary( `peak_power` and `phases`, which must then be left unset; `wavelength` still sets the grid's reference wavelength. launch : mapping, sequence of mapping, or None, optional - Launch the input into chosen modes (SEP 41, S5): a mapping from a mode's - display label or index to its share, or to ``{"share": s, "phase": phi}`` - with the phase in radians (optics convention), as - ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, "phase": 1.57}}``; or a list of - such mappings, one for each pulse in `pulses`. A label may leave out a - parity the set carries once (``"HE1,1"`` for ``"HE1,1:even"``). The shares - are energy fractions, normalised to one for each pulse, and the launch is - recorded as ``configuration["modal_launch"]`` - (``docs/guide/setup/pulses.md``). ``None``, the default, puts the input in - the first mode. - modes : int or sequence of Mode, optional - How many HE_1m modes to propagate, or the mode set itself. A set is checked - for orthonormality. Supplied sets may include TabulatedHollowMode or - TabulatedMode with their own tables. A supplied set **carries its own - geometry**: `radius` must agree with every mode's core radius along the run, - and a `model`, `loss` or `cladding` declared away from its default must be - every mode's own, or the call is refused by name rather than the argument - ignored. - mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its - effective area, instead of resolving it on the radial rule - (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is - 1.768 times ``P/A_eff``), and ``peak_intensity`` stays on the axis. - ``False``, the default, resolves the mode. Refused beside more than one - mode and beside `n_r`, `n_theta` or a `radial_rule`. - lattice : TubeLattice or None, optional - Tube geometry for an integer mode count, selecting Vincetti modes. - Refused with supplied modes, which carry their own geometry, and with - ``model="reduced"``. None selects ordinary capillary modes. - wall : solprop.modes.CoherentWall or None, optional - Declare the scalar **coherent** wall block: the outgoing Robin boundary - condition condensed onto the retained basis, which couples every ``HE_1m`` - channel to every other with a definite phase instead of giving each its own - Marcatili attenuation. It needs the contiguous set ``HE_11`` to ``HE_1N`` -- - or, with ``impedance="te_tm"``, one azimuthal order -- on a constant or a - varying guide, and a scalar run; it carries the default ``"input"`` noise - and refuses ``"modified"``. ``numpy_ref`` and ``native`` execute it; - every other mode set or geometry is refused by name. ``None``, the - default, carries no wall: the modes keep their own Marcatili loss, and a - multimode taper its taper coupling (below). - taper_coupling : bool, optional - On a multimode capillary whose radius varies along z, carry the - **taper coupling** -- the connection ```` that moves - power between the ``HE_1m`` channels as the bore changes -- lossless or - not. ``True``, the default; ``False`` is the explicit opt-out to the - **adiabatic** route, on which each channel follows its local mode and - exchanges no power. It is refused beside `wall`, which always carries - the connection, and has no effect on one channel, a constant bore (a - pressure gradient included, which does not change the modes' shape) or - a set that is not the contiguous ``HE_11`` to ``HE_1N`` block - (``docs/guide/waveguides/capillaries.md``, "Tapers"). - model : {'full', 'reduced'}, optional - Which Marcatili model the modes use; beside a supplied set, refused unless - it is the default or the modes' own. - loss : bool, float or callable, optional - Propagation loss of the modes; beside a supplied set, refused unless it - is the default or the modes' own. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is the package's cited silica. Beside a - supplied set, refused unless it is the default or the modes' own. + Launch the input into chosen modes or channels (SEP 41, S5): a mapping + from a display label or index, resolved as `channel` resolves it, to a + share, or to ``{"share": s, "phase": phi}`` with the phase in radians + (optics convention), as ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, + "phase": 1.57}}``; or a list of such mappings, one for each pulse in + `pulses`. A label may leave out a parity the set carries once, and one + that leaves a parity open between two rows is refused. On a basis whose + flux metric is the identity the shares are energy fractions, normalised to + one for each pulse; on a guide that declares a change of basis they are + relative amplitudes squared, scaled so that the flux-metric energy is the + pulse's, a coordinate split. The launch is recorded as + ``configuration["modal_launch"]`` (``docs/guide/setup/pulses.md``). + Refused beside `channel`, `incident` and `coupling`. ``None``, the + default, puts the input in the first mode or in `channel`. susceptibility : GasSusceptibility or None, optional A gas susceptibility replacing the one `gas` selects, for a comparison against another code's refractive data. Refused for a fraction mapping @@ -4775,11 +3450,26 @@ def from_capillary( without data; explicit labels or :class:`~solprop.physdata.raman_types.MolecularOptions` select components and terms, and a named unavailable model fails explicitly. + mode_average : bool, optional + Propagate the one mode **mode-averaged**, as a uniform disc of its + effective area, instead of resolving it on the transverse rule + (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the + area-mean field, below the on-axis one (``HE_11``'s axis intensity is + 1.768 times ``P/A_eff``), and ``peak_intensity`` stays on the axis. + ``False``, the default, resolves the mode. Refused beside more than one + mode and beside `n_r`, `n_theta` or a `radial_rule`. + Refused beside `provider`. n_r : int or None, optional - Radial nodes of the fixed rule. ``None`` takes - :func:`default_radial_order` of the mode count. + Radial nodes of the fixed rule, or the x-axis nodes of a Cartesian product + or slab rule. ``None`` takes + :func:`~solprop.driver.routes.hollow.default_radial_order` of the mode + count, or :func:`~solprop.modes.quadrature.cartesian_nodes` of the set's + highest transverse order where that is larger. n_theta : int or None, optional - Azimuthal nodes. Refused for a set of purely radial modes, which need none. + Azimuthal nodes, or the y-axis nodes of a Cartesian product rule, whose + default is :func:`~solprop.modes.quadrature.cartesian_nodes`. Refused + for a set of purely radial modes, which need none, and for a slab set, + which is uniform in y. radial_rule : str, optional The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose embedded Gauss subset is the quadrature alarm's error estimate; @@ -4790,16 +3480,14 @@ def from_capillary( ionisation_field_range : tuple of float or None, optional Field amplitudes in V/m the rate table must cover. ``None`` derives them from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.simulation.default_field_range`. + :func:`solprop.driver.inputs.default_field_range`. absorber : solprop.boundaries.Absorber, False or None, optional The collar rates; ``None`` builds the default collar of the grid, and ``False`` switches both soft collars off and keeps the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How the temporal collar is applied (ADR-025): inside every stage, the - default, or once per accepted step. The low-call Adams methods of - SEP 13 step much further with ``'split_step'``, whose first-order - splitting error stays inside the collar. + How the temporal collar is applied (ADR-025); ``None`` is the default, + inside every stage. boundary_check : solprop.monitor.BoundaryCheck or None, optional How the run watches its time window's edges (SEP 21): the default warns, then stops, when light above the shot-noise floor reaches them; ``None`` @@ -4812,12 +3500,13 @@ def from_capillary( Entropy of the noise generator. rtol : float or None, optional Relative step tolerance; the default is - :data:`~solprop.driver.defaults.CAPILLARY_RTOL`, the capillary default. - Calibrate it for a production run (``docs/dev/stepper.md`` section 3). + :data:`~solprop.driver.defaults.WAVEGUIDE_RTOL`, the modal capillary + default. Calibrate it for a production run (``docs/dev/stepper.md`` + section 3). atol : float, optional Absolute step tolerance, in the square root of watts. dz_max : float or None, optional - Cap on the step, m; ``None`` takes half the capillary. + Cap on the step, m; ``None`` takes half the waveguide. dz_init : float or None, optional First step, m; ``None`` takes ``length * 1e-4``. schedule : solprop.stepper.schedule.FixedSchedule or None, optional @@ -4825,22 +3514,17 @@ def from_capillary( integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``Multistep()``: the low-call exponential - Adams method, one response evaluation per step. ``RungeKutta()`` is the - interaction-picture DOPRI5 march, for a cheap response or a state too - large for the Adams history, and faster on a long run with Raman or - plasma (SEP 37). ``LowStorageRungeKutta()`` replays - `schedule` in three registers (SEP 24). + ``None``, the default, takes ``RungeKutta()``, the interaction-picture + DOPRI5 march (SEP 37). ``Multistep()`` is the low-call exponential Adams + method. ``LowStorageRungeKutta()`` replays `schedule` in three registers. interpolate_planes : bool, optional Observe the save planes inside accepted steps instead of landing on them (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous extension with ``RungeKutta()``. Default ``False``. step_planes : int or None, optional Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); ``0`` - saves the requested planes only. ``None``, the default, is ``0``, or - ``1`` with a `tdse` response, which saves every accepted step without - imposing a landing (SEP 20 section 5.8). + march's own positions, beside the requested planes (SEP 14); ``None``, + the default, and ``0`` save the requested planes only. save_planes : sequence of float or None, optional Positions to save at, m. Give this or `n_save`. n_save : int or None, optional @@ -4865,5686 +3549,341 @@ def from_capillary( fallback, and import or construction errors stay visible. polarisation : str, float or None, optional Refused; this path is scalar. + taper_coupling : bool, optional + Carry the taper coupling of a supplied capillary set on a varying radius, as + :meth:`from_capillary` does; ``False`` is the opt-out to the adiabatic + route. Refused as ``False`` beside a `provider`, whose moving guide carries + its own connection. materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: a gas or - fraction mapping of built-in gases, ``"air"`` and ``Gas.Air`` resolve - through :func:`solprop.physdata.species` and - :meth:`solprop.physdata.mixtures.Mixture.air` with it, and the default - cladding (``cladding=None`` with a numbered mode set) is optmat's - recommended silica. A supplied mixture, mode set, cladding or susceptibility - is taken as it is. The run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``. - - tdse : solprop.nonlinear.tdse.TDSEGasResponse or None, optional - A helium TDSE response, SAE or haCC, used as the complete electronic - response of the fill instead of the Kerr, plasma and Raman responses - (SEP 20 section 5.2); ``plasma``, ``raman``, ``preionisation``, - ``plasma_integration``, ``ionisation_field_range`` and ``thg`` must then - be left unset and ``kerr`` true. ``backend="auto"`` resolves to - ``"numpy_ref"``, the only backend that executes it; statistics default to - those the route defines; omitted `save_planes` and `n_save` save only the - final position beside every accepted step. An atomic window whose duration - is unresolved takes `trange`. ``None`` keeps the rate-based responses. - tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None, optional - Atomic runtime resources of `tdse`: device, memory limit, rows per call - and call cap. ``None`` beside `tdse` takes ``TDSEExecution()``; refused - without `tdse`. - - Returns - ------- - Simulation - The simulation, ready to translate and run. - - Raises - ------ - DriverError - If a keyword is missing, inconsistent, or names something this path does - not carry. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_capillary( - ... 125e-6, 0.1, "He", (0.8, 0.1), wavelength=800e-9, - ... wavelength_limits=(150e-9, 2e-6), trange=200e-15, - ... fwhm=10e-15, energy=100e-6, modes=2, n_save=3, - ... ) - >>> len(simulation.modes), simulation.settings.quadrature.n_nodes - (2, 33) - >>> sorted(simulation.profiles) - ['core_radius', 'density', 'pressure'] - """ - return cls._from_hollow( - radius=radius, - length=length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - modes=modes, - mode_average=mode_average, - lattice=lattice, - wall=wall, - taper_coupling=taper_coupling, - model=model, - loss=loss, - cladding=cladding, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - materials=MaterialSelection() if materials is None else materials, - tdse=tdse, - tdse_execution=tdse_execution, - ) - - @classmethod - @_one_material_context - def from_waveguide( - cls, - modes: Sequence[Mode], - length: float, - gas: Gas | str | Mapping[str, float] | Mixture | None = None, - pressure: Pressure | None = None, - *, - provider: ModalGuide | None = None, - channel: int | str | None = None, - incident: SpatialPulse | None = None, - coupling: CouplingModel | None = None, - kerr_form: KerrForm = "real", - observable_rtol: float | None = None, - observable_atol: float | None = None, - pressure_floor: float | None = None, - vacuum_end: VacuumEnd | None = None, - wavelength: float, - wavelength_limits: tuple[float, float] | None = None, - trange: float | None = None, - grid: TimeGrid | None = None, - envelope: bool = False, - fwhm: float | None = None, - width: float | None = None, - shape: str = "gauss", - energy: float | None = None, - peak_power: float | None = None, - phases: Sequence[float] | np.ndarray = (), - pulses: Sequence[Pulse] | None = None, - launch: ModalLaunch | None = None, - susceptibility: GasSusceptibility | None = None, - temperature: float | ZProfile | None = None, - kerr: bool = True, - thg: bool | None = None, - plasma: bool - | IonisationRate - | Mapping[str, bool | IonisationRate] - | None = None, - preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None, - plasma_integration: IntegrationPolicy | None = None, - raman: bool - | Sequence[str] - | Mapping[str, bool | MolecularOptions] - | None = None, - mode_average: bool = False, - n_r: int | None = None, - n_theta: int | None = None, - radial_rule: RadialRule = "gauss_kronrod", - ionisation_field_range: tuple[float, float] | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - noise: NoiseSetting = "default", - seed: int | None = None, - rtol: float | None = None, - atol: float = WAVEGUIDE_ATOL, - dz_max: float | None = None, - dz_init: float | None = None, - schedule: FixedSchedule | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - interpolate_planes: bool = False, - step_planes: int | None = None, - save_planes: Sequence[float] | np.ndarray | None = None, - n_save: int | None = None, - stats: StatsSpec | None = None, - polarisation_taper: bool = False, - node_budget: int | None = None, - backend: str = "auto", - polarisation: str | float | None = None, - taper_coupling: bool = True, - materials: MaterialSelection | None = None, - ) -> "Simulation": - """Build a pulse simulation in a waveguide: a gas-filled mode set, or a guide. - - **Without `provider`** this is the gas-filled route: modes keep their - geometry, cladding and loss and receive the declared fill, their nonzero - field components determine the transverse quadrature layout, and rectangular - polarisation partners are separate rows with separate operators. A hollow - planar set (:class:`~solprop.modes.slab.SlabMode`) is uniform in y and runs - **per unit width**: `energy` is J/m, `peak_power` W/m, and the run records - ``transverse_measure = "per_unit_width"``. Step-index fibres use - ``prop_gnlse(fibre=...)`` instead. - - **With `provider`** this is the **general modal route** - (``docs/guide/waveguides/general-modal.md``): a guide that solves its own - three-component - E and H at quadrature nodes, whose material regions own their responses and - whose channels are Poynting-normalised, so that a channel amplitude is a - true-power amplitude by construction. It is qualified on ``numpy_ref`` and the - native CPU backend, a declared split block's coupled - flow included, and propagates constant geometry on a **field** grid. - Every gas keyword is refused by name beside a provider, because a guide's - materials are its regions' and not a fill. - - Parameters - ---------- - modes : sequence of solprop.modes.protocol.Mode - Non-empty gas-filled mode set with a common transverse geometry. Each - mode is rebound to this constructor's fill (``with_mixture``): its own - gas, pressure and temperature -- a temperature path included -- are - replaced by `gas`, `pressure` and `temperature`, and only its geometry is - kept. Beside - `provider` it must be empty or exactly the guide's own ordered channels: - a guide supplies its channels itself, and the provider has already - validated their order against its fields. - length : float - Waveguide length, m. - gas : Gas, str, Mapping, Mixture or None - Built-in gas, mole fractions keyed by species name, or ordered populations - carrying their own density paths. A mapping uses total pressure. Required - without `provider` and refused beside one. - pressure : float, sequence, ZProfile or None, optional - None uses one bar for a gas or fractions. Omit pressure beside a Mixture; - every explicit pressure is refused because it already carries its paths. - Fill pressure in **bar**: a number for a constant fill, a pair - ``(p_in, p_out)`` for the steady-flow gradient of - :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the - positions and the pressures there for - :meth:`solprop.profiles.PressureGradient.from_stages`. - provider : solprop.modes.guide.ModalGuide or None, optional - A guide built by - :meth:`solprop.modes.guide.ModalGuide.from_provider`, so that it carries - the sampling its dense E/H reference is rebuilt from. Its knots must - span the run's whole **active** band, which starts at half the lowest - declared frequency; a narrower guide is refused with both bands in the - message. - channel : int or str or None, optional - Which channel the pulse enters, by index or by display label - (``"HE1,1:even"``); ``None`` takes the first, as a gas-filled multimode - run puts a single pulse in its fundamental. The launch is the pulse's - amplitude placed in that channel unchanged -- a Poynting basis needs no - conversion -- so the launched true-power energy is the pulse's. Requires - `provider`. - incident : solprop.pulses.SpatialPulse or None, optional - An **incident beam** to launch instead of a named channel: the beam - and its temporal pulse, whose energy or peak power is met over the - **whole** beam in the paraxial measure of the guide's core medium - (:meth:`solprop.pulses.SpatialPulse.incident`). The beam is coupled into - the channels by :func:`solprop.coupling.coupler.couple` under `coupling` - (SEP 31): it is sampled on a radial plane that resolves it, evaluated at - the guide's nodes, and its coordinates are recovered with the flux - pairing and the forward closure ``H_t = Y0 z x E_t``. The recovered - amplitudes are launched as they are. **Light the channels do not - capture is lost, not restored**: a beam much wider than the core - launches a small share of its energy, and a beam the channels cannot see - launches nothing, which is a valid zero launch. The coupling's - :class:`~solprop.coupling.coupler.CouplingReport` -- incident and launched - energy per frequency and in total, and its admission budget -- is - recorded as ``configuration["launch"]``, and the launch's provenance as - ``configuration["coupling"]``. The beam is launched at its waist - (``propz=0``) and centred on the guide's axis. The pulse keywords - (`fwhm`, `width`, `energy`, `peak_power`, `phases`, `pulses` and - `shape`) are refused beside it, because the beam carries its own - pulse. Requires `provider` and `coupling`, and is refused beside - `channel`. - coupling : solprop.coupling.models.CouplingModel or None, optional - The model an `incident` beam is coupled under: - ``CouplingModel("vector_paraxial", model_error=...)``, whose budget the - guide's contrast, closure and angular content must fit (SEP 31, section - 4.3); a strongly guiding core is refused. Requires `incident`. - kerr_form : {'real', 'analytic'}, optional - Which instantaneous cubic a general modal run's material regions respond - with. ``"real"``, the default, is the real-field cubic - ``P = eps0 chi3 (E.E) E`` the legacy field route evaluates, third harmonic - included; ``"analytic"`` is the analytic-signal form - ``eps0 chi3 (2 |E|^2 E + (E.E) E*) / 4``, which carries the fundamental - band alone and is what a comparison with an envelope route wants. The real - form needs a grid with ``n_to >= 2 n_t``, which is ``FieldGrid``'s default - ``ffac=6``, and is refused on a narrower one. Under one of SEP 6's - dispersive electronic policies a region's gas fill takes the dispersive - cubic in the matching sector: ``"real"`` its full cubic, ``"analytic"`` - its number-conserving sector. Requires `provider`. - observable_rtol, observable_atol : float or None, optional - Requested accuracy of the named physical observables of a general modal run. - ``None``, the only value accepted, means **the solver's own** `rtol` and - `atol`: the route's basis is orthonormal in true power, so the controller's - weighted norm on the amplitudes already is a norm on the physical channel - powers. A different number is refused by name, because the separate error - allocation it would ask for -- over the table's interpolation, the - transverse rule and the step -- is not implemented. Require `provider`. - pressure_floor : float or None, optional - Lowest pressure the fill is allowed to reach, bar; ``None`` takes - :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest - stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true - vacuum end, which warns, because ``dp/dz`` diverges there. - Refused beside a constant pressure or a pressure profile, which have - no end to apply it to. - vacuum_end : {'choked', 'floor'} or None, optional - How a pressure gradient's outer end below its choked pressure is treated. - ``None`` (the default) means ``"choked"`` for a circular hollow guide: the - fill follows the bore (Pennetta et al. 2019, eq. 6) and an end exhausting - into vacuum takes the choked pressure of the flow - (:func:`solprop.gasflow.steady_fill`), with a warning if the flow is not - laminar. ``"floor"`` concerns the ends only: it keeps the declared end - pressures, raised only to `pressure_floor`, for a comparison with a model - that imposes them, while a taper's fill still follows the bore (on a - uniform bore, bitwise the uniform law). Refused beside a constant - pressure, a pressure profile or a Mixture, which have no end to choke, - and on the general route. Every mode declaring ``core_radius`` -- every - ``MarcatiliMode`` subclass, a ``TabulatedMode`` built from arrays - included -- is taken as a circular bore of that radius; a rectangular - guide keeps the uniform law. - wavelength : float - Reference wavelength, m, and the pulse's centre wavelength. - wavelength_limits : tuple of float or None, optional - Shortest and longest wavelength of the frequency window, m. Required unless - `grid` is given. - trange : float or None, optional - Requested width of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid or None, optional - A grid of one's own, instead of the three keywords above; the only way to - reach ``dt``, ``ffac`` or the transform size. - envelope : bool, optional - Propagate an envelope. Plasma is off by default; explicit ``plasma=True`` - selects cycle averaging of instantaneous rates. Plasma forbids THG. - fwhm : float or None, optional - Pulse duration at half maximum, s. - width : float or None, optional - Natural width of a sech pulse, s; give this or `fwhm`. - shape : {'gauss', 'sech'}, optional - Transform-limited shape of the input. - energy : float or None, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float or None, optional - Peak power, W, realised after the spectral phases. - phases : sequence of float, optional - Taylor spectral phases about `wavelength`, optics convention: CEP in rad, - group delay in s, GDD in s^2, and so on. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, - `peak_power` and `phases`, which must then be left unset; `wavelength` still - sets the grid's reference wavelength. - launch : mapping, sequence of mapping, or None, optional - Launch the input into chosen modes or channels (SEP 41, S5): a mapping - from a display label or index, resolved as `channel` resolves it, to a - share, or to ``{"share": s, "phase": phi}`` with the phase in radians - (optics convention), as ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, - "phase": 1.57}}``; or a list of such mappings, one for each pulse in - `pulses`. A label may leave out a parity the set carries once, and one - that leaves a parity open between two rows is refused. On a basis whose - flux metric is the identity the shares are energy fractions, normalised to - one for each pulse; on a guide that declares a change of basis they are - relative amplitudes squared, scaled so that the flux-metric energy is the - pulse's, a coordinate split. The launch is recorded as - ``configuration["modal_launch"]`` (``docs/guide/setup/pulses.md``). - Refused beside `channel`, `incident` and `coupling`. ``None``, the - default, puts the input in the first mode or in `channel`. - susceptibility : GasSusceptibility or None, optional - A gas susceptibility replacing the one `gas` selects, for a comparison - against another code's refractive data. Refused for a fraction mapping - or an existing mixture, which own their species models. - temperature : float, ZProfile or None, optional - Gas temperature, K, or a path of it along z in m. Sets the density the - pressure path maps to, so it changes both the dispersion and the - nonlinearity. A path also weights a steady-flow gradient by the gas's - ``eta(T) T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's - populations independent parameters, and holds the rotational Raman - populations at one reference temperature with a - :class:`~solprop.exceptions.RamanTemperatureWarning` - (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path - is the number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture - owns its temperature: beside one, a temperature must be omitted or be the - mixture's own (the same number or path object), and any other is refused by - name. - kerr : bool, optional - Include the Kerr response. - thg : bool or None, optional - Include third-harmonic generation in the Kerr term; ``None`` takes - ``not envelope``. - plasma : bool, IonisationRate, Mapping or None, optional - Include photoionisation. ``None`` takes ``not envelope``. ``True`` selects - optmat's recommended rate model of each built-in gas (SEP 3, D1; - :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the - selection's ``ionisation`` names), for a single gas and for every - component of a mixture; a custom species needs its own rate. A - rate object is used as given; a mapping selects a rate or switch per - component label, and omitted labels are off. - preionisation : float, ZProfile, Mapping or None, optional - Initial ionised fraction in [0, 1], globally or per label: a number, or a - path along z in m giving the fraction ionised before the pulse arrives at - each position. None and omitted mapping labels retain the component's - existing declaration. - plasma_integration : {'band', 'trapezoid'} or None, optional - How the field-grid plasma forms its population and free-current integrals - (SEP 18): ``'band'`` filters each integrand with taps designed for the - grid, exact to a certified 1e-5 across the active band; ``'trapezoid'`` - is the plain cumulative trapezoid, short by up to 11 % per integral at - the top of the default active band, which the frozen witness comparisons - use. ``None`` takes :class:`~solprop.nonlinear.responses.PlasmaResponse`'s - default. Refused with envelope plasma, whose currents are converted - exactly, and when the plasma is off. - raman : bool, sequence of str, Mapping or None, optional - ``None`` (the default) turns the Raman response on for every species - whose optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- leaves - an atom without one, and warns (:class:`solprop.exceptions.SolpropWarning`) - for a molecule whose response is provisional (N2O, unless the selection's - ``raman`` is ``"provisional"``) rather than leaving it out silently; with - ``kerr=False`` the default is off. ``False`` disables it; ``True`` selects - the available models of a mixture and is refused by name for a single gas - without data; explicit labels or - :class:`~solprop.physdata.raman_types.MolecularOptions` select components - and terms, and a named unavailable model fails explicitly. - mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its - effective area, instead of resolving it on the transverse rule - (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is - 1.768 times ``P/A_eff``), and ``peak_intensity`` stays on the axis. - ``False``, the default, resolves the mode. Refused beside more than one - mode and beside `n_r`, `n_theta` or a `radial_rule`. - Refused beside `provider`. - n_r : int or None, optional - Radial nodes of the fixed rule, or the x-axis nodes of a Cartesian - product or slab rule. ``None`` takes :func:`default_radial_order` of - the mode count, or :func:`~solprop.modes.quadrature.cartesian_nodes` of - the set's highest transverse order where that is larger. - n_theta : int or None, optional - Azimuthal nodes, or the y-axis nodes of a Cartesian product rule, whose - default is :func:`~solprop.modes.quadrature.cartesian_nodes`. Refused - for a set of purely radial modes, which need none, and for a slab set, - which is uniform in y. - radial_rule : str, optional - The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose - embedded Gauss subset is the quadrature alarm's error estimate; - ``"gauss"``, with no alarm; or ``"field_scale"`` and - ``"field_scale_radau"``, Gaussian in the HE_11 field scale for expensive - responses, which need exactly one Marcatili HE_11 mode and have no alarm - (``docs/guide/waveguides/radial-quadrature.md``). - ionisation_field_range : tuple of float or None, optional - Field amplitudes in V/m the rate table must cover. ``None`` derives them - from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.simulation.default_field_range`. - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars off and keeps the hard band mask - (:func:`solprop.boundaries.no_absorber`; SEP 21). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How the temporal collar is applied (ADR-025); ``None`` is the default, - inside every stage. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, - then stops, when light above the shot-noise floor reaches them; ``None`` - watches nothing, for a window kept narrow on purpose. - noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Entropy of the noise generator. - rtol : float or None, optional - Relative step tolerance; the default is - :data:`~solprop.driver.defaults.WAVEGUIDE_RTOL`, the modal capillary - default. Calibrate it for a production run (``docs/dev/stepper.md`` - section 3). - atol : float, optional - Absolute step tolerance, in the square root of watts. - dz_max : float or None, optional - Cap on the step, m; ``None`` takes half the waveguide. - dz_init : float or None, optional - First step, m; ``None`` takes ``length * 1e-4``. - schedule : solprop.stepper.schedule.FixedSchedule or None, optional - A fixed schedule to replay instead of stepping adaptively. - integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional - The method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture - DOPRI5 march (SEP 37). ``Multistep()`` is the low-call exponential Adams - method. ``LowStorageRungeKutta()`` replays `schedule` in three registers. - interpolate_planes : bool, optional - Observe the save planes inside accepted steps instead of landing on them - (SEP 14): through the Adams march's own polynomial, or DOPRI5's - continuous extension with ``RungeKutta()``. Default ``False``. - step_planes : int or None, optional - Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); ``None``, - the default, and ``0`` save the requested planes only. - save_planes : sequence of float or None, optional - Positions to save at, m. Give this or `n_save`. - n_save : int or None, optional - How many planes to save, counting ``z = 0``. - stats : solprop.stats.StatsSpec or None, optional - Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` for a multimode run with the profiles, - or for a mode-averaged one under `mode_average`, plus every statistic - only a backend can compute. - polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation as well as - absorbing the field through the linear operator. Default ``False``: the - taper costs a systematic error of the size of the field's content in the - collar, and the rate absorber acts either way - (``docs/guide/numerics/boundaries.md``). - node_budget : int or None, optional - Bytes of node-chunk buffers the response pass may hold, an execution - setting; ``None``, the default, leaves the backend's automatic choice. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if - it isn't installed or this processor is below its baseline. There is no - fallback, and import or construction errors stay visible. - polarisation : str, float or None, optional - Refused; this path is scalar. - taper_coupling : bool, optional - Carry the taper coupling of a supplied capillary set on a varying radius, as - :meth:`from_capillary` does; ``False`` is the opt-out to the adiabatic - route. Refused as ``False`` beside a `provider`, whose moving guide carries - its own connection. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the fill from optmat under this selection: a gas or fraction mapping - of built-in gases, ``"air"`` and ``Gas.Air`` resolve through - :func:`solprop.physdata.species` and - :meth:`solprop.physdata.mixtures.Mixture.air` with it. The supplied modes, - their claddings, a supplied mixture or susceptibility, and a provider's - material regions are taken as they are. The run records its material - manifest. ``None`` (the default) is - ``MaterialSelection()``. - - Returns - ------- - Simulation - A configured simulation, ready to translate or run. - - Raises - ------ - DriverError - If the modes cannot bind a gas mixture, a gas keyword is given beside a - provider, a general-guide keyword is given without one, or any supplied - setting is invalid. - """ - if provider is not None: - return cls._from_general( - provider, - modes, - length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - channel=channel, - incident=incident, - coupling=coupling, - kerr_form=kerr_form, - observable_rtol=observable_rtol, - observable_atol=observable_atol, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - mode_average=mode_average, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - taper_coupling=taper_coupling, - materials=MaterialSelection() if materials is None else materials, - ) - for name, value in ( - ("channel", channel), - ("incident", incident), - ("coupling", coupling), - ("kerr_form", None if kerr_form == "real" else kerr_form), - ("observable_rtol", observable_rtol), - ("observable_atol", observable_atol), - ): - if value is not None: - msg = ( - f"{name}= belongs to the general modal route and needs a " - "provider= beside it; the gas-filled waveguide route launches " - "one pulse into its lowest mode and controls the step alone" - ) - raise DriverError(msg) - if gas is None: - msg = ( - "from_waveguide needs a gas fill for its supplied modes, or a " - "provider= whose material regions own their own laws and responses " - "(the general modal route)" - ) - raise DriverError(msg) - return cls._from_hollow( - radius=None, - modes=modes, - mode_average=mode_average, - waveguide=True, - length=length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - route="waveguide", - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - taper_coupling=taper_coupling, - materials=MaterialSelection() if materials is None else materials, - ) - - @classmethod - def _from_general( - cls, - provider: ModalGuide, - modes: Sequence[Mode], - length: float, - *, - channel: int | str | None, - incident: SpatialPulse | None, - coupling: CouplingModel | None, - kerr_form: KerrForm, - observable_rtol: float | None, - observable_atol: float | None, - wavelength: float, - wavelength_limits: tuple[float, float] | None, - trange: float | None, - grid: TimeGrid | None, - fwhm: float | None, - width: float | None, - shape: str, - energy: float | None, - peak_power: float | None, - phases: Sequence[float] | np.ndarray, - pulses: Sequence[Pulse] | None, - absorber: Absorber | Literal[False] | None, - absorber_scheme: AbsorberScheme | None, - boundary_check: BoundaryCheck | None, - noise: NoiseSetting, - seed: int | None, - rtol: float | None, - atol: float, - dz_max: float | None, - dz_init: float | None, - schedule: FixedSchedule | None, - save_planes: Sequence[float] | np.ndarray | None, - n_save: int | None, - stats: StatsSpec | None, - backend: str, - materials: MaterialSelection | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - launch: ModalLaunch | None = None, - interpolate_planes: bool = False, - step_planes: int | None = None, - polarisation_taper: bool = False, - **refused: object, - ) -> "Simulation": - """Build a general modal run from a guide that solves its own fields. - - The provider half of :meth:`from_waveguide`, kept apart because it shares no - physics with the gas-filled one: no fill, no mixture, no static transverse - quadrature, no declared response list, and a launch that crosses no - normalisation boundary. Every keyword of the gas-filled path arrives in - `refused` and is refused by name if it was set. - - - ``length``: guide length, m. - - ``channel``: which channel the pulse enters; ``None`` takes the first. - - ``observable_rtol``, ``observable_atol``: accepted only as ``None``, which - means the solver's own tolerances. - - ``fwhm``, ``width``, ``shape``, ``energy``, ``peak_power``, ``phases``, - ``pulses``: the input pulse, as :func:`_shaped_pulse` takes it. - - ``absorber``: the collar rates; ``None`` builds the grid's default collar, and - ``False`` switches both soft collars off and keeps the hard band mask. - - ``absorber_scheme``: how the temporal collar is applied; ``None`` is the - default scheme. - - ``stats``: which statistics to record; ``None`` takes :func:`_general_stats`. - - ``integrator``: the method that steps the run; ``None`` takes ``Multistep()``, - and ``LowStorageRungeKutta()`` replays `schedule` in three registers. - - ``step_planes``: also save the end of every `step_planes`-th accepted step; - ``None`` is ``0``. - - ``**refused``: every gas-filled keyword; each is refused by name when it is - set. - """ - _refuse_gas_keywords(refused) - provider = _selected_guide( - provider, MaterialSelection() if materials is None else materials - ) - for name, value in ( - ("observable_rtol", observable_rtol), - ("observable_atol", observable_atol), - ): - if value is not None: - msg = ( - f"{name}={value!r}: a general modal run's observables are its " - "channel amplitudes in a basis orthonormal in true power, so " - "the controller's own rtol and atol already bound them, and " - "the separate allocation a different number would ask for -- " - "over the table's frequency interpolation, the transverse rule " - "and the step -- is not implemented. Pass None, and set rtol " - "and atol" - ) - raise DriverError(msg) - working = _capillary_grid( - grid, - length, - wavelength, - wavelength_limits, - trange, - envelope=False, - thg=False, - ) - if working.kind != "field": - msg = ( - f"a general modal guide propagates on a field grid and this run is " - f"on an {working.kind} grid: its leaves are tabulated on the " - "canonical increasing coarse axis, and its source is the " - "analytic-signal polarisation of a carrier-resolved field" - ) - raise DriverError(msg) - channels = tuple(provider.mode_collection.modes) - if tuple(modes) not in ((), channels): - msg = ( - f"a general modal guide supplies its own {len(channels)} ordered " - f"channel(s), validated against the provider's fields, and this " - f"call passes {len(tuple(modes))} mode(s) of its own: pass " - "modes=() beside provider=, or the guide's own " - "provider.mode_collection.modes" - ) - raise DriverError(msg) - planes = _save_planes(length, save_planes, n_save) - refuse_beside(launch, channel=channel, incident=incident, coupling=coupling) - pulse = _general_pulse( - incident, - channel, - shape, - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - ) - built = (pulse.temporal if isinstance(pulse, SpatialPulse) else pulse).build( - working - ) - try: - route = general_modal_route( - provider, - working, - z_max=float(np.max(planes)), - omega_reference=float(working.omega0), - kerr_form=kerr_form, - selection=MaterialSelection() if materials is None else materials, - # The pulse is built first so that the route's occupied-support basis - # gate is weighted by where this run's light actually is rather than - # over the whole declared band. The launch's channel distribution does - # not enter that weight -- it is summed over channels -- so the pulse - # alone is enough, which is what lets a projected launch, which needs - # the route's own dual, still be weighted by its own spectrum. It - # reaches no leaf. - launch=built, - ) - except TranslationError as error: - # The bridge belongs to the translation layer and says so; a caller of - # `from_waveguide` is told what this entry point promises, and - # `DriverError` is what its `Raises` names. The message is the bridge's - # own, unchanged, and the cause is kept. - raise DriverError(str(error)) from error - beam = pulse if isinstance(pulse, SpatialPulse) else None - if (beam is None) != (coupling is None): - msg = ( - "an incident beam is coupled under a declared model: give incident= " - "with coupling=CouplingModel('vector_paraxial', model_error=...), or " - "neither" - ) - raise DriverError(msg) - launched: dict[str, object] | None = None - if beam is not None: - # Checked before the port is built: a guide whose first region is off the - # axis has no core for a beam to enter through. - _core_index(provider.regions, working) - # The launch is the coupling's, installed through `with_input` below. - field0 = np.zeros((len(channels), 1, working.n_omega), dtype=np.complex128) - elif launch is None: - field0 = _in_channel(built, provider, channel) - else: - # A declared change of basis carries a dense flux metric, under which the - # shares are a coordinate split; in the provider's own coordinates the - # metric is the identity and they are energy fractions (SEP 41, S5). - field0, launched = launch_pulse( - # No beam, so the input is the shaped pulse. - cast("Pulse", pulse), - launch, - working, - provider.mode_collection.channel_labels, - metric=metric_at(route.metric_tables, 0.0), - ) - boundary, scheme = _boundary(absorber, absorber_scheme, working) - controller, default = _keyword_controller("waveguide", rtol, atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route="waveguide", - save_planes=planes, - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=0 if step_planes is None else step_planes, - absorber=boundary, - absorber_scheme=scheme, - boundary_check=boundary_check, - # The route carries its nodes, its materials and its normalisation in - # its own leaves: the static transverse quadrature, the component - # selection, the density path and the index override are all refused - # by the translation layer and are not settings of this run. - stats=_general_stats() if stats is None else stats, - polarisation_taper=polarisation_taper, - backend=backend, - ) - noise_model, resolution = resolve_noise("waveguide", noise, seed) - draw = functools.partial(_general_noise, noise_model, working, guide=provider) - simulation = cls( - working, - channels, - [], - field0, - settings=settings, - noise=draw(seed), - noise_resolution=resolution, - configuration={ - "waveguide": { - "modes": [ModeRecord.from_mode(mode) for mode in channels], - "length": length, - "provider": dict(provider.identity), - }, - **({} if beam is not None else {"pulse": pulse}), - **({} if launched is None else {"modal_launch": launched}), - }, - general=route, - guide=provider, - materials=materials, - ) - _redrawable(simulation, draw, seed) - if beam is None: - return simulation - return _incident_launch(simulation, beam, cast("CouplingModel", coupling)) - - @classmethod - def _from_hollow( # noqa: PLR0915 - the interface's keyword surface - cls, - radius: float | ZProfile | None, - length: float, - gas: Gas | str | Mapping[str, float] | Mixture, - pressure: Pressure | None = None, - *, - waveguide: bool = False, - pressure_floor: float | None = None, - vacuum_end: VacuumEnd | None = None, - wavelength: float, - wavelength_limits: tuple[float, float] | None = None, - trange: float | None = None, - grid: TimeGrid | None = None, - envelope: bool = False, - fwhm: float | None = None, - width: float | None = None, - shape: str = "gauss", - energy: float | None = None, - peak_power: float | None = None, - phases: Sequence[float] | np.ndarray = (), - pulses: Sequence[Pulse] | None = None, - launch: ModalLaunch | None = None, - modes: int | Sequence[Mode] = 1, - mode_average: bool = False, - lattice: TubeLattice | None = None, - wall: CoherentWall | None = None, - taper_coupling: bool = True, - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, - susceptibility: GasSusceptibility | None = None, - temperature: float | ZProfile | None = None, - kerr: bool = True, - thg: bool | None = None, - plasma: bool - | IonisationRate - | Mapping[str, bool | IonisationRate] - | None = None, - preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None, - plasma_integration: IntegrationPolicy | None = None, - raman: bool - | Sequence[str] - | Mapping[str, bool | MolecularOptions] - | None = None, - n_r: int | None = None, - n_theta: int | None = None, - radial_rule: RadialRule = "gauss_kronrod", - ionisation_field_range: tuple[float, float] | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - noise: NoiseSetting = "default", - seed: int | None = None, - rtol: float | None = None, - atol: float = WAVEGUIDE_ATOL, - dz_max: float | None = None, - dz_init: float | None = None, - schedule: FixedSchedule | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - route: Literal["capillary", "waveguide"] = "capillary", - interpolate_planes: bool = False, - step_planes: int | None = None, - save_planes: Sequence[float] | np.ndarray | None = None, - n_save: int | None = None, - stats: StatsSpec | None = None, - polarisation_taper: bool = False, - node_budget: int | None = None, - backend: str = "auto", - polarisation: str | float | None = None, - materials: MaterialSelection | None = None, - tdse: TDSEGasResponse | None = None, - tdse_execution: TDSEExecution | None = None, - ) -> "Simulation": - """Build a simulation of a pulse in a gas-filled hollow waveguide. - - The shared body of :meth:`from_capillary` and :meth:`from_waveguide`. The mode - set is the first `modes` HE_1m modes of the capillary unless a set is given; the - transverse integral is a fixed quadrature rule - (``docs/guide/waveguides/mode-sets.md``); the - gas density along z is the pressure path through the equation of state, and the - same path sets the modes' core index, so a gradient fill disperses and broadens - consistently. - - See ``docs/guide/setup/running.md`` for the discussion and a worked example. - - - ``radius``: core radius, m, constant or along z. - - ``length``: capillary length, m. - - ``gas``: Built-in gas, mole fractions keyed by species name, or ordered - populations carrying their own density paths. A mapping uses total pressure. - - ``pressure``: None uses one bar for a gas or fractions. Omit pressure beside a - Mixture; every explicit pressure is refused because it already carries its - paths. Fill pressure in **bar**: a number for a constant fill, a pair ``(p_in, - p_out)`` for the steady-flow gradient of - :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the - positions and the pressures there for - :meth:`solprop.profiles.PressureGradient.from_stages`. - - ``pressure_floor``: lowest pressure the fill is allowed to reach, bar; - ``None`` takes :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the - highest stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true - vacuum end, which warns, because ``dp/dz`` diverges there. Refused beside a - constant pressure or a pressure profile, which have no end to apply it to. - - ``vacuum_end``: how a pressure gradient's outer end below its choked pressure - is treated. ``None`` (the default) means ``"choked"`` for a circular hollow - guide: the fill follows the bore (Pennetta et al. 2019, eq. 6) and an end - exhausting into vacuum takes the choked pressure of the flow - (:func:`solprop.gasflow.steady_fill`), with a warning if the flow is not - laminar. ``"floor"`` concerns the ends only: it keeps the declared end - pressures, raised only to `pressure_floor`, for a comparison with a model that - imposes them, while a taper's fill still follows the bore (on a uniform bore, - bitwise the uniform law). Refused beside a constant pressure, a pressure - profile or a Mixture, which have no end to choke, and on the general route. - Every mode declaring ``core_radius`` -- every ``MarcatiliMode`` subclass, a - ``TabulatedMode`` built from arrays included -- is taken as a circular bore of - that radius; a rectangular guide keeps the uniform law. - - ``wavelength``: reference wavelength, m, and the pulse's centre wavelength. - - ``wavelength_limits``: shortest and longest wavelength of the frequency - window, m. Required unless `grid` is given. - - ``trange``: requested width of the time window, s. Required unless `grid` is - given. - - ``grid``: a grid of one's own, instead of the three keywords above; the only - way to reach ``dt``, ``ffac`` or the transform size. - - ``fwhm``: pulse duration at half maximum, s. - - ``width``: natural width of a sech pulse, s; give this or `fwhm`. - - ``shape``: Transform-limited shape of the input. - - ``energy``: pulse energy, J. Give this or `peak_power`. - - ``peak_power``: peak power, W, realised after the spectral phases. - - ``phases``: Taylor spectral phases about `wavelength`, optics convention: CEP - in rad, group delay in s, GDD in s^2, and so on. - - ``pulses``: several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, duration, - energy or peak power, phase and delay; the input is the sum of their fields - (:class:`~solprop.pulses.PulseSum`). Replaces the single-pulse keywords - `fwhm`, `width`, `shape`, `energy`, `peak_power` and `phases`, which must then - be left unset; `wavelength` still sets the grid's reference wavelength. - - ``launch``: the modes each pulse enters, with their shares and phases, as - :meth:`from_capillary` documents it; ``None`` puts the input in the first - mode. - - ``mode_average``: propagate the one mode **mode-averaged**: a uniform disc of - the mode's effective area instead of a transverse rule - (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 - times ``P/A_eff``), while ``peak_intensity`` stays on the axis. ``False``, the - default, resolves the mode on the radial rule. Refused beside more than one - mode and beside `n_r`, `n_theta` or a `radial_rule`. - - ``lattice``: tube geometry for an integer mode count, selecting Vincetti - modes. Refused with supplied modes, which carry their own geometry, and with - ``model="reduced"``. None selects ordinary capillary modes. - - ``wall``: declare the scalar **coherent** wall block: the outgoing Robin - boundary condition condensed onto the retained basis, which couples every - ``HE_1m`` channel to every other with a definite phase instead of giving each - its own Marcatili attenuation. It needs the contiguous set ``HE_11`` to - ``HE_1N`` -- or, with ``impedance="te_tm"``, one azimuthal order -- on a - constant or a varying guide, and a scalar run; it carries the default - ``"input"`` noise and refuses ``"modified"``. ``numpy_ref`` and ``native`` - execute it; every other mode set or geometry is refused by name. ``None``, - the default, carries no wall: the modes keep their own Marcatili loss, and a - multimode taper its taper coupling (below). - - ``taper_coupling``: on a multimode capillary whose radius varies along z, - carry the **taper coupling** -- the connection ```` that - moves power between the ``HE_1m`` channels as the bore changes -- lossless or - not. ``True``, the default; ``False`` is the explicit opt-out to the - **adiabatic** route, on which each channel follows its local mode and - exchanges no power. It is refused beside `wall`, which always carries the - connection, and has no effect on one channel, a constant bore (a pressure - gradient included, which does not change the modes' shape) or a set that is - not the contiguous ``HE_11`` to ``HE_1N`` block - (``docs/guide/waveguides/capillaries.md``, "Tapers"). - - ``model``: which Marcatili model the modes use; beside a supplied set, refused - unless it is the default or the modes' own. - - ``loss``: propagation loss of the modes; beside a supplied set, refused unless - it is the default or the modes' own. - - ``cladding``: cladding index; ``None`` is the package's cited silica. Beside a - supplied set, refused unless it is the default or the modes' own. - - ``temperature``: gas temperature, K, or a path of it along z in m. Sets the - density the pressure path maps to, so it changes both the dispersion and the - nonlinearity. A path also weights a steady-flow gradient by the gas's ``eta(T) - T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's populations - independent parameters, and holds the rotational Raman populations at one - reference temperature with a - :class:`~solprop.exceptions.RamanTemperatureWarning` - (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path is the - number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture owns its - temperature: beside one, a temperature must be omitted or be the mixture's own - (the same number or path object), and any other is refused by name. - - ``thg``: include third-harmonic generation in the Kerr term; ``None`` takes - ``not envelope``. - - ``plasma``: include photoionisation. ``None`` takes ``not envelope``. ``True`` - selects optmat's recommended rate model of each built-in gas (SEP 3, D1; - :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the - selection's ``ionisation`` names), for a single gas and for every component of - a mixture; a custom species needs its own rate. A rate object is used as - given; a mapping selects a rate or switch per component label, and omitted - labels are off. - - ``preionisation``: initial ionised fraction in [0, 1], globally or per label: - a number, or a path along z in m giving the fraction ionised before the pulse - arrives at each position. None and omitted mapping labels retain the - component's existing declaration. - - ``plasma_integration``: how the field-grid plasma forms its population and - free-current integrals (SEP 18): ``'band'`` filters each integrand with taps - designed for the grid, exact to a certified 1e-5 across the active band; - ``'trapezoid'`` is the plain cumulative trapezoid, short by up to 11 % per - integral at the top of the default active band, which the frozen witness - comparisons use. ``None`` takes - :class:`~solprop.nonlinear.responses.PlasmaResponse`'s default. Refused with - envelope plasma, whose currents are converted exactly, and when the plasma is - off. - - ``raman``: ``None`` (the default) turns the Raman response on for every - species whose optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- - leaves an atom without one, and warns - (:class:`solprop.exceptions.SolpropWarning`) for a molecule whose response is - provisional (N2O, unless the selection's ``raman`` is ``"provisional"``) - rather than leaving it out silently; with ``kerr=False`` the default is off. - ``False`` disables it; ``True`` selects the available models of a mixture and - is refused by name for a single gas without data; explicit labels or - :class:`~solprop.physdata.raman_types.MolecularOptions` select components and - terms, and a named unavailable model fails explicitly. - - ``n_r``: radial nodes of the fixed rule. ``None`` takes - :func:`default_radial_order` of the mode count. - - ``ionisation_field_range``: field amplitudes in V/m the rate table must cover. - ``None`` derives them from the input's peak field over the axis and the - transverse nodes; see :func:`solprop.driver.simulation.default_field_range`. - - ``absorber``: the collar rates; ``None`` builds the default collar of the - grid, and ``False`` switches both soft collars off and keeps the hard band - mask (:func:`solprop.boundaries.no_absorber`; SEP 21). - - ``boundary_check``: how the run watches its time window's edges (SEP 21): the - default warns, then stops, when light above the shot-noise floor reaches them; - ``None`` watches nothing, for a window kept narrow on purpose. - - ``rtol``: relative step tolerance; ``None`` takes the route's default - (:func:`~solprop.driver.defaults.default_rtol`). Calibrate it for a production - run (``docs/dev/stepper.md`` section 3). - - ``dz_max``: cap on the step, m; ``None`` takes half the capillary. - - ``dz_init``: first step, m; ``None`` takes ``length * 1e-4``. - - ``integrator``: the method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, takes - ``Multistep()``: the low-call exponential Adams method, one response - evaluation per step. ``RungeKutta()`` is the interaction-picture DOPRI5 march, - for a cheap response or a state too large for the Adams history. - ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). - - ``route``: the interface building the run, whose row of - :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` gives what the caller left: - ``"capillary"`` (its TDSE variant beside a TDSE response) or ``"waveguide"`` - (its slab variant for a slab mode set). - - ``step_planes``: also save the end of every `step_planes`-th accepted step - (SEP 14); ``None`` is ``0``, or ``1`` with `tdse` (SEP 20 section 5.8). - - ``save_planes``: positions to save at, m. Give this or `n_save`. - - ``stats``: which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` for a multimode run with the profiles, or - for a mode-averaged one under `mode_average`, plus every statistic only a - backend can compute. - - ``node_budget``: bytes of node-chunk buffers the response pass may hold, an - execution setting; ``None``, the default, leaves the backend's automatic - choice. - - ``backend``: registry name, or ``"auto"`` (the default) for ``native``, which - refuses if it isn't installed or this processor is below its baseline. There - is no fallback, and import or construction errors stay visible. - - ``materials``: take the run's built-in materials from optmat under this - selection: a gas or fraction mapping of built-in gases, ``"air"`` and - ``Gas.Air`` resolve through :func:`solprop.physdata.species` and - :meth:`solprop.physdata.mixtures.Mixture.air` with it, and the default - cladding (``cladding=None`` with a numbered mode set) is optmat's recommended - silica. A supplied mixture, mode set, cladding or susceptibility is taken as - it is. The run records its material manifest. ``None`` (the default) is - ``MaterialSelection()``. - """ - # The TDSE response owns the electronic response: build no other. - atomic, plasma, raman, backend, n_save, step_planes = _entry_overrides( - _tdse_entry( - tdse, - tdse_execution, - trange=trange, - kerr=kerr, - mode_average=mode_average, - unset={ - "plasma": plasma, - "raman": raman, - "preionisation": preionisation, - "plasma_integration": plasma_integration, - "ionisation_field_range": ionisation_field_range, - "thg": thg, - }, - backend=backend, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - ), - plasma, - raman, - backend, - n_save, - step_planes, - ) - if raman and not kerr: - msg = ( - "raman=True needs kerr=True: a medium with a Raman response has an " - "electronic one too, and on a field grid the drive form reaches the " - "kernel through the Kerr term (docs/guide/media/raman.md)" - ) - raise DriverError(msg, parameter=("raman", "kerr"), rule="raman-needs-kerr") - if polarisation is not None: - msg = ( - f"polarisation={polarisation!r}: this path is scalar and propagates " - "one transverse component per mode row. Declare the polarisation " - "in the modes instead -- a rectangular or slab guide's own " - "polarisation, each partner a row of its own -- or, for a vector " - "capillary run, a polarisation-resolved mode set, which is not " - "validated end to end yet" - ) - raise DriverError( - msg, parameter="polarisation", rule="scalar-capillary-polarisation" - ) - _check_wall_declaration( - wall, - model=model, - loss=loss, - lattice=lattice, - taper_coupling=taper_coupling, - ) - thg, plasma = ( - (not envelope) if thg is None else thg, - (not envelope) if plasma is None else plasma, - ) - active_plasma = ( - any(value is not False for value in plasma.values()) - if isinstance(plasma, Mapping) - else plasma is not False - ) - _refuse_plasma_settings( - plasma_integration, envelope=envelope, active=active_plasma, thg=thg - ) - - working = _capillary_grid( - grid, - length, - wavelength, - wavelength_limits, - trange, - envelope=envelope, - thg=thg, - ) - materials = MaterialSelection() if materials is None else materials - _check_selection_carrier(materials, working.reference_wavelength, wavelength) - bore = ( - _flow_radius(modes, pressure) - if waveguide - else as_profile(cast("float | ZProfile", radius)) - ) - # The fill's own models warn about the conditions they are evaluated at, - # which the gas keyword chose. - with _warnings_about("gas", _MATERIAL_WARNINGS): - mixture = capillary_fill( - gas, - pressure, - length=length, - temperature=temperature, - floor=pressure_floor, - susceptibility=susceptibility, - preionisation=preionisation, - radius=bore, - vacuum_end=vacuum_end, - selection=materials, - ) - _warn_boreless_gradient(mixture, bore) - if waveguide: - if isinstance(modes, int): - raise DriverError( - "from_waveguide requires supplied gas-filled modes", - parameter="modes", - ) - mode_set = _given_gas_modes(modes, mixture) - else: - mode_set = _capillary_modes( - modes, - cast("float | ZProfile", radius), - mixture, - length=length, - lattice=lattice, - model=model, - loss=loss, - cladding=_default_cladding(cladding) - if isinstance(modes, int) - else cladding, - ) - rule = ( - _no_quadrature(mode_set, n_r, n_theta, radial_rule=radial_rule) - if mode_average - else _capillary_quadrature(mode_set, n_r, n_theta, radial_rule=radial_rule) - ) - profiles = mixture_profiles(mixture) - if waveguide: - for mode in mode_set: - paths: Mapping[str, ZProfile] = getattr(mode, "parameter_paths", {}) - profiles.update( - { - name: path - for name, path in paths.items() - if name not in profiles - and (not path.is_constant or name == "core_radius") - } - ) - else: - profiles["core_radius"] = as_profile(cast("float | ZProfile", radius)) - if raman is None: - # The default follows the Kerr switch: a run without the electronic - # response is a deliberately reduced model, and a field-grid Raman term - # cannot stand without a Kerr term beside it anyway. - raman = kerr and _default_raman( - mixture, provisional=materials.raman == "provisional" - ) - if atomic is not None: - responses = [atomic.response] - elif ( - isinstance(gas, (Gas, str)) - and not is_air(gas) - and isinstance(raman, bool) - and not isinstance(plasma, Mapping) - and not isinstance(preionisation, Mapping) - ): - # The Raman response is optmat's, built from the fill (SEP 3); the other - # responses keep this path. - with _warnings_about("gas", _MATERIAL_WARNINGS): - responses = _capillary_responses( - gas, - mixture.components[0].species, - kerr=kerr, - plasma=plasma, - preionisation=0.0 if preionisation is None else preionisation, - integration=plasma_integration, - wavelength=wavelength, - envelope=envelope, - # The single gas's electronic response is optmat's, at the - # carrier or dispersive, as the selection's electronic policy - # says (SEP 6). - electronic=electronic_response( - mixture.components[0].species, - selection=materials, - wavelength=wavelength, - thg=thg, - ) - if kerr - else None, - selection=materials, - ) - # optmat's Raman response is the raman keyword's choice. - with _about("raman"), _warnings_about("raman", _MATERIAL_WARNINGS): - responses += ( - _optmat_raman(gas, mixture, materials, thg, wavelength, length) - if raman - else [] - ) - else: - with _warnings_about("gas", _MATERIAL_WARNINGS): - responses = list( - responses_for( - mixture, - kerr=kerr, - thg=thg, - plasma=plasma, - raman=raman, - wavelength=wavelength, - envelope=envelope, - length=length, - selection=materials, - plasma_integration=plasma_integration, - ) - ) - with _about(("preionisation", "plasma")): - _refuse_idle_preionisation(mixture, responses) - pulse = _shaped_pulse( - shape, - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - ) - field0, launched = _hollow_input(pulse, launch, working, mode_set.modes) - ionising = any( - isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - for one in responses - ) - boundary, scheme = _boundary(absorber, absorber_scheme, working) - # A hollow guide's default is the grid's collar, so there is always one. - _warn_capillary_walkoff(mode_set.modes, working, cast("Absorber", boundary)) - responses, field_range, reference = _ionisation_inputs( - responses, - ionisation_field_range, - _InputPeak(field0, mode_set, working, rule) if ionising else None, - ) - row = ( - "waveguide slab" - if any(isinstance(mode, SlabMode) for mode in mode_set.modes) - else "capillary tdse" - if atomic is not None - else route - ) - controller, default = _keyword_controller(row, rtol, atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route=row, - save_planes=_save_planes(length, save_planes, n_save), - # Relative defaults, so that the first step and the cap scale with the - # capillary. - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - density=None, - absorber=boundary, - absorber_scheme=scheme, - boundary_check=boundary_check, - quadrature=rule, - components=None - if rule is None - else _hollow_components(supplied=not isinstance(modes, int)), - ionisation_field_range=field_range, - node_budget=node_budget, - stats=( - _with_integration_statistics( - _hollow_stats( - with_plasma=ionising, - mixture=mixture, - profiles=profiles, - rule=rule, - ) - if waveguide - else _capillary_stats( - with_plasma=ionising, mixture=mixture, averaged=rule is None - ), - responses, - ) - if stats is None - else stats - ) - if atomic is None or stats is not None - else _tdse_stats( - _capillary_stats( - with_plasma=False, mixture=mixture, averaged=rule is None - ) - ), - polarisation_taper=polarisation_taper, - backend=backend, - tdse_execution=None if atomic is None else atomic.execution, - ) - noise_model, resolution = resolve_noise(row, noise, seed) - draw = functools.partial( - _shot_noise, noise_model, working, n_modes=len(mode_set) - ) - simulation = cls( - working, - mode_set.modes, - responses, - field0, - settings=settings, - noise=draw(seed), - noise_resolution=resolution, - profiles=profiles, - wall=wall, - taper_coupling=taper_coupling, - materials=materials, - configuration={ - "medium": mixture, - **( - { - "waveguide": { - "modes": [ModeRecord.from_mode(mode) for mode in mode_set], - "length": length, - "temperature": mixture.temperature, - } - } - if waveguide - else { - "capillary": { - "radius": radius, - "length": length, - "temperature": mixture.temperature, - } - } - ), - "pulse": pulse, - **({} if launched is None else {"modal_launch": launched}), - **({} if atomic is None else {"tdse_entry": atomic.record}), - }, - ) - # A TDSE run keeps no selection: a mixture update must not rebuild the - # rate-based responses in its place. - simulation._mixture_selection = ( - None - if atomic is not None - else ResponseSelection( - kerr, - thg, - plasma, - raman, - wavelength, - envelope, - length, - selection=materials, - reference_field=reference, - field_range=field_range, - plasma_integration=plasma_integration, - ) - ) - return _redrawable(simulation, draw, seed) - - -@dataclasses.dataclass(frozen=True) -class _LoadedRun: - """What a runner's march needs, its input held by its start alone. - - Attributes - ---------- - problem : solprop.translate.problem.PropagationProblem - The derived problem, without its input (:func:`_without_input`). - start : solprop.backends.session.Start - The input's start. The run's session takes its field, so that nothing holds - the input through the march (issue #447). - handler : solprop.output.schema.Output - Where the planes go. - metadata : solprop.output.schema.RunMetadata - The run's record. - retention : solprop.output.schema.FieldRetention - Which planes keep their field. - boundary_monitor : solprop.monitor.BoundaryMonitor or None - The run's monitor of the window's edges. - material_log : solprop.physdata.selection.MaterialLog - The derived run's material log, which the march goes on with. - """ - - problem: PropagationProblem - start: Start - handler: Output - metadata: RunMetadata - retention: FieldRetention - boundary_monitor: BoundaryMonitor | None - material_log: MaterialLog - - -class SimulationRunner(Displayed): - """One translated problem and one backend, run for many inputs (SEP 22 section 5.2). - - Built by :meth:`Simulation.runner`: the problem is translated (or the cached - translation reused) and one backend is constructed, once. Each - :meth:`run` then derives the input with :meth:`Simulation.with_input`, loads it - into the same backend and runs the save-plane loop, returning that input's own - handler. A large problem so pays its backend's allocation and transform planning - once rather than for every input. - - A run on the reused backend repeats a fresh backend's run, to the rounding that - separates any two runs: loading an input restores everything the march reads - (the reference's :meth:`solprop.backends.host.driver.HostDriver.set_initial`, the - native backend's own load). Each run's record is - its own input's, so each has its own configuration hash. - - Use it as a context manager, or call :meth:`close`: the backend holds about - fifteen copies of the state until then. A closed runner refuses :meth:`run`. A - run that raises leaves the runner open, since the next input resets everything; - a backend that closed itself closes the runner. - - Parameters - ---------- - simulation : Simulation - The run to repeat. Its settings, and the translation they fix, are used as - they are; only the input changes from run to run. - - Raises - ------ - DriverError - If the simulation has a TDSE response, whose atomic binding has its own - lifecycle and is never reused. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> from solprop.pulses import SechPulse - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, - ... ) - >>> def pulse(power): - ... return SechPulse(wavelength=835e-9, width=100e-15, peak_power=power) - >>> with simulation.runner() as runner: - ... outputs = [ - ... runner.run(pulse(power), configuration={"peak_power": power}, - ... progress="none") - ... for power in (5.0, 9.8) - ... ] - >>> [output.n_saved for output in outputs], runner.closed - ([3, 3], True) - """ - - def __init__(self, simulation: Simulation) -> None: - """Translate, admit and construct the backend, once.""" - problem = simulation.translate() - if tdse_descriptor(problem.route) is not None: - msg = ( - "a TDSE response binds an atomic runtime with its own lifecycle, which " - "a runner cannot reuse across inputs; run each input with " - "simulation.with_input(...).run()" - ) - raise DriverError(msg) - settings = simulation.settings - request = simulation._run_request() - self._simulation = simulation - self._problem = problem - self._request = request - self._lock = threading.Lock() - self._closed = False - self.runs = 0 - with _optmat.material_context(simulation._material_log): - _warn_sampled_peak(settings.quadrature, request.observation.stats) - self._backend = select( - settings.backend, - problem, - request, - grid=simulation.grid, - modes=simulation.modes, - profiles=simulation.profiles, - options=settings.execution_options(), - ) - - def __repr__(self) -> str: - """Return a one-line description: the simulation, the runs and the state. - - Returns - ------- - str - For example ``SimulationRunner(Simulation(...), 3 runs, open)``. - """ - return line( - "SimulationRunner", - [ - ("", repr(self._simulation)), - ("", f"{self.runs} runs"), - ("", "closed" if self._closed else "open"), - ], - ) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - return "SimulationRunner", [ - ("simulation", repr(self._simulation)), - ("backend", self._simulation.settings.backend), - ("runs", self.runs), - ("state", "closed" if self._closed else "open"), - ] - - def __enter__(self) -> "SimulationRunner": - """Return the runner, so that a ``with`` block closes it on the way out. - - Returns - ------- - SimulationRunner - This runner. - """ - return self - - def __exit__(self, *details: object) -> None: - """Close the runner when the block ends, whatever happened inside it. - - Parameters - ---------- - *details : object - The exception being raised, if any; it is not suppressed. - """ - self.close() - - @property - def closed(self) -> bool: # numpydoc ignore=RT01 - """Whether the runner, and its backend, are closed.""" - return self._closed - - @property - def backend(self) -> Backend: # numpydoc ignore=RT01 - """Backend every run is stepped on.""" - return self._backend - - def close(self) -> None: - """Close the backend, releasing its buffers; calling it twice does nothing. - - A run in progress on another thread finishes first: the close waits for it. - """ - with self._lock: - self._close_held() - - def _close_held(self) -> None: - """Close the backend, the runner's lock being held by the caller.""" - if self._closed: - return - self._closed = True - self._backend.close() - - def run( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - *, - output: Output | Path | str | None = None, - configuration: Mapping[str, object] | None = None, - noise: NoiseRealisation | Literal["keep"] | None = "keep", - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, - input_identity: str | None = None, - ) -> Output: - """Run one input on the runner's backend and return its handler. - - Parameters - ---------- - initial : Pulse, SpatialPulse or numpy.ndarray - The input, as :meth:`Simulation.with_input` takes it. An array the record - keeps by digest (:func:`~solprop.output.schema.recorded_input`) is not - copied: it is read while this call loads it, and the runner holds no - reference to it, nor to any copy, once it is loaded, so a caller that - keeps none itself has it freed before the first step. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Where this input's planes go: a handler, a path for an HDF5 file, or - ``None`` for a new :class:`~solprop.output.memory.MemoryOutput`. - configuration : collections.abc.Mapping or None, optional - Caller keys recorded in this input's run record and hash, such as the - delay of a scan (:meth:`Simulation.with_input`). - noise : NoiseRealisation, {'keep'} or None, optional - The noise, as for :meth:`Simulation.with_input`: kept by default, or - replaced by another realisation of the same model. - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the line reporter's periodic lines, s. - input_identity : str or None, optional - What an array input is, recorded in its place and not checked, so that its - bytes are not digested (:meth:`Simulation.with_input`, whose warning - applies: a wrong identity passes a resume and a scan's cache). - - Returns - ------- - solprop.output.schema.Output - This input's handler, closed; a memory handler stays readable. - - Raises - ------ - DriverError - If the runner is closed or already running an input; if the derived - problem is not the runner's but for its input (a different launch - position, or a shared leaf replaced); if the noise model differs from - the runner's, which the backend could not carry; or if `input_identity` - is empty or names anything but an array input. - """ - if not self._lock.acquire(blocking=False): - msg = ( - "this runner is already running an input: one backend steps one input " - "at a time, so build one runner per thread or process" - ) - raise DriverError(msg) - try: - if self._closed: - msg = "this runner is closed; build another with simulation.runner()" - raise DriverError(msg) - loaded = self._load( - initial, - output=output, - configuration=configuration, - noise=noise, - input_identity=input_identity, - ) - # The start holds the input, and every frame that saw it has returned but - # this one; the session takes it from the start, so the march holds no - # volume for it (issue #447). - del initial - return self._march( - loaded, progress=progress, progress_period=progress_period - ) - except BaseException: - # A backend that closed itself cannot run another input. - if self._backend.closed: - self._close_held() - raise - finally: - self._lock.release() - - def _takes(self, simulation: Simulation) -> bool: - """Return whether a simulation is this runner's but for its input.""" - try: - check_derived_problem( - self._problem, - simulation.translate(), - self._request, - simulation._run_request(), - ) - except DriverError as error: - logger.debug("a new backend for this run: %s", error) - return False - return True - - def _run_point( - self, - simulation: Simulation, - output: Output, - *, - progress: ProgressReporter | ProgressChoice | None, - stop: StopFlag | None, - ) -> None: - """Run a simulation this runner :meth:`_takes`, as its own ``run`` would. - - For a scan's worker, which has built the point's simulation and holds its - record already. Unlike :meth:`run` it honours `stop`, and the handler's record - is the simulation's own. - """ - with self._lock: - simulation._run(output, progress=progress, stop=stop, kept=self._backend) - self.runs += 1 - - def _load( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - *, - output: Output | Path | str | None, - configuration: Mapping[str, object] | None, - noise: NoiseRealisation | Literal["keep"] | None, - input_identity: str | None, - ) -> _LoadedRun: - """Derive one input's run, open its handler and build the input's start. - - The derived simulation and its problem are this frame's alone: they go when it - returns, and the start alone holds the converted launch of a modal route or the - view of the input, until the run's session takes it. - """ - derived = self._simulation.with_input( - _borrowed_input(initial, stated=input_identity is not None), - noise=noise, - configuration=configuration, - input_identity=input_identity, - ) - problem = derived.translate() - request = derived._run_request() - check_derived_problem(self._problem, problem, self._request, request) - settings = self._simulation.settings - with _optmat.material_context(derived._material_log): - metadata = derived.metadata() - handler, _ = _open_output(output, metadata, resume=False) - retention = field_retention(request, settings.fields, settings.field_planes) - monitor = derived._boundary_monitor() - start = Start.entrance( - problem, noise=None if isinstance(noise, str) else _realisation(problem) - ) - return _LoadedRun( - _without_input(problem), - start, - handler, - metadata, - retention, - monitor, - derived._material_log, - ) - - def _march( - self, - loaded: _LoadedRun, - *, - progress: ProgressReporter | ProgressChoice | None, - progress_period: float, - ) -> Output: - """Run the loaded input's save-plane loop and finish its handler. - - - ``progress_period``: seconds between periodic lines, s. - """ - settings = self._simulation.settings - problem, handler = loaded.problem, loaded.handler - with _optmat.material_context(loaded.material_log): - run_problem( - problem, - self._backend, - handler, - start=loaded.start, - metadata=loaded.metadata, - stats_spec=self._request.observation.stats, - progress=progress, - progress_period=progress_period, - resume_history=settings.resume_history, - fields=loaded.retention.fields, - field_planes=loaded.retention.field_planes, - boundary_monitor=loaded.boundary_monitor, - ) - reports = _peak_field_reports(problem, handler, self._simulation.responses) - if reports and isinstance(handler, PlaneStore): - handler.annotate( - PEAK_FIELDS_ANNOTATION, - [cast("JsonValue", report.to_dict()) for report in reports], - ) - self.runs += 1 - return handler - - -def _borrowed_input( - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, *, stated: bool -) -> Pulse | SpatialPulse | SpatialPulseSum | np.ndarray: - """Return a runner's input as its private derived simulation may take it, uncopied. - - A derived simulation copies an array so that its record and its run outlive the - caller's changes. A runner's lives only until the load, on the caller's thread and - inside the call, so an array needs no copy unless the record outlives it: one - recorded inline stays a copy, in the handler's record. - - - Returns: a read-only view of a complex128 array the record does not hold, marked - as held so that nothing copies it; `initial` itself otherwise. The caller's own - flags are not touched. - """ - if ( - isinstance(initial, np.ndarray) - and initial.dtype == np.complex128 - and (stated or not _recorded_inline(initial)) - ): - return _hold(np.asarray(initial).view()) - return initial - - -def _without_input(problem: PropagationProblem) -> PropagationProblem: - """Return a loaded problem without its input, for the march that follows the load. - - The save-plane loop reads the launch position and never the launch, so the state - is replaced by a broadcast of one zero, which holds no volume (:class:`~solprop. - translate.problem.Initial`). The problem is good for the march alone. - """ - placeholder = np.broadcast_to( - np.zeros(1, dtype=np.complex128), problem.initial.field0.shape - ) - return dataclasses.replace( - problem, initial=dataclasses.replace(problem.initial, field0=_hold(placeholder)) - ) - - -def _realisation(problem: PropagationProblem) -> NoiseRealisation | None: - """Return a derived problem's noise as the backend loads it, or ``None``. - - - Returns: its realisation, or ``None`` without noise. - """ - initial = problem.initial - if initial.noise0 is None or initial.noise_model == "none": - return None - return NoiseRealisation( - spectrum=initial.noise0, seed=None, model=initial.noise_model - ) - - -_SHARED_LEAVES = ( - "grids", - "linear", - "transform", - "responses", -) -"""The leaves :meth:`Simulation.with_input` shares with the translated problem. - -The route is shared but for a spatial route's transverse design, whose check -:meth:`Simulation.with_input` reruns on the new launch: its variant and its other -leaves are compared beside these (:func:`check_derived_problem`). The statistics are -the request's, shared too.""" - - -def check_derived_problem( - constructed: PropagationProblem, - derived: PropagationProblem, - constructed_request: RunRequest, - derived_request: RunRequest, -) -> None: - """Refuse a derived problem that is not the constructed one but for its input. - - What a :class:`SimulationRunner` checks before loading an input into the backend - it built for `constructed` (SEP 22 section 5.2): the leaves - :meth:`Simulation.with_input` shares are the same objects, the statistics too, the - stepper and the flags, which it rebuilds, agree field by field (the save planes - elementwise), and the launch position and the noise model are the backend's. - - Parameters - ---------- - constructed : solprop.translate.problem.PropagationProblem - The problem the backend was built for. - derived : solprop.translate.problem.PropagationProblem - The problem of one input. - constructed_request : solprop.translate.run_request.RunRequest - The request the backend was built for. - derived_request : solprop.translate.run_request.RunRequest - The request of the input's run. - - Raises - ------ - DriverError - Naming the first leaf or setting that differs. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> from solprop.driver.simulation import check_derived_problem - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, - ... width=100e-15, peak_power=9.8, shape="sech", - ... ) - >>> problem = simulation.translate() - >>> derived = simulation.with_input(problem.initial.field0) - >>> check_derived_problem( - ... problem, derived.translate(), simulation._run_request(), - ... derived._run_request(), - ... ) is None - True - """ - for name in _SHARED_LEAVES: - if getattr(derived, name) is not getattr(constructed, name): - msg = ( - f"the input's problem does not share the runner's {name}: a runner " - "steps one translated problem and changes only its input" - ) - raise DriverError(msg) - if derived_request.observation.stats is not constructed_request.observation.stats: - msg = ( - "the input's problem does not share the runner's stats_spec: a runner " - "steps one translated problem and changes only its input" - ) - raise DriverError(msg) - if ( - type(derived.route) is not type(constructed.route) - or tdse_of(derived.route) is not tdse_of(constructed.route) - or flux_of(derived.route) is not flux_of(constructed.route) - ): - msg = ( - "the input's problem does not share the runner's route: a runner steps " - "one translated problem and changes only its input" - ) - raise DriverError(msg) - if derived.initial.z != constructed.initial.z: - msg = ( - f"the input is launched at z = {derived.initial.z!r} m and the runner's " - f"problem at {constructed.initial.z!r} m: the save planes and the " - "schedule's cursor belong to the problem the backend was built for, so a " - "different initial_z needs its own simulation" - ) - raise DriverError(msg) - if derived.initial.noise_model != constructed.initial.noise_model: - msg = ( - f"the input carries the noise model {derived.initial.noise_model!r} and " - f"the runner's problem {constructed.initial.noise_model!r}: the backend " - "carries the model it was built with, and would drop or misapply another" - ) - raise DriverError(msg) - for record, built, name in ( - (derived_request.stepper, constructed_request.stepper, "stepper"), - (derived.flags, constructed.flags, "flags"), - ): - for entry in dataclasses.fields(record): - if not _same_setting( - getattr(built, entry.name), getattr(record, entry.name) - ): - msg = ( - f"the input's {name}.{entry.name} differs from the runner's: a " - "runner steps one translated problem and changes only its input" - ) - raise DriverError(msg) - - -def _simulation_mixture(simulation: Simulation) -> Mixture: - """Require a material declaration the update can identify by component.""" - if simulation.medium is not None and isinstance( - simulation.medium.material, Mixture - ): - return simulation.medium.material - materials = [getattr(mode, "mixture", None) for mode in simulation.modes] - if not materials or not isinstance(materials[0], Mixture): - raise DriverError("with_mixture needs a Mixture in the modes or spatial medium") - material = materials[0] - for index, other in enumerate(materials[1:], 1): - if serialise(other) != serialise(material): - raise DriverError(f"mode {index} carries a different mixture") - return material - - -def _refuse_plasma_settings( - integration: IntegrationPolicy | None, *, envelope: bool, active: bool, thg: bool -) -> None: - """Refuse plasma settings that cannot act together. - - Envelope plasma cannot carry the third harmonic, and a running-integral rule - belongs to the field-grid plasma column: given beside envelope plasma, or with - the plasma off, nothing would read it. - """ - if envelope and active and thg: - msg = "envelope plasma cannot be combined with THG" - raise DriverError( - msg, parameter=("envelope", "plasma", "thg"), rule="envelope-plasma-no-thg" - ) - if integration is None: - return - if envelope: - msg = ( - f"plasma_integration={integration!r} selects the field-grid plasma " - "column's running-integral rule; envelope plasma converts its currents " - "with exact inverse-frequency factors and takes no such rule" - ) - raise DriverError( - msg, - parameter=("plasma_integration", "envelope"), - rule="plasma-integration-field-grid", - ) - if not active: - msg = ( - f"plasma_integration={integration!r} is given but the plasma is off; " - "turn it on (plasma=True) or drop plasma_integration" - ) - raise DriverError( - msg, - parameter=("plasma_integration", "plasma"), - rule="plasma-integration-needs-plasma", - ) - - -def _refuse_idle_preionisation(mixture: Mixture, responses: Sequence[object]) -> None: - """Refuse a declared initial ionised fraction that no plasma channel carries. - - A component's preionisation reaches the run only through its plasma or - envelope-plasma response. Without one the declaration would be recorded in the - run's metadata and ignored by the propagation, so it is refused by name. - """ - carried = { - _population_label(one, mixture) - for one in responses - if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - } - for component in mixture.components: - value = component.preionisation - declared = isinstance(value, ZProfile) or value != 0 - if declared and component.label not in carried: - msg = ( - f"preionisation is declared for {component.label!r}, which has no " - "plasma response, so no channel would carry it; turn its plasma on " - "(plasma=True or plasma={label: True}) or drop the preionisation" - ) - raise DriverError(msg) - - -def _population_label( - response: PlasmaResponse | EnvelopePlasmaResponse, mixture: Mixture -) -> str: - """Resolve an existing plasma declaration to its already-validated population.""" - if response.species is not None: - return response.species - if len(mixture.components) == 1: - return mixture.labels[0] - name = getattr(response.gas, "name", str(response.gas)) - return next( - component.label - for component in mixture.components - if component.species.name == name - ) - - -def _same_operator_function( - source: Simulation, - updated: Simulation, - old: Mixture, - new: Mixture, - expansion: ParameterExpansion | None, -) -> ParameterExpansion | None: - """Keep terms only when every dependency outside their axes is unchanged. - - - ``expansion``: previously sampled coefficients and axis names. - - Returns: the old coefficients when the function is identical, else None with a - log. - """ - if source.settings.prefactor_index != updated.settings.prefactor_index: - logger.info("Mixture update: expansion reuse refused; prefactor_index changed") - return None - if expansion is None: - return None - if len(old.components) > 1 and ( - (old.shared is None) != (new.shared is None) - or ( - old.shared is not None - and (old.fractions != new.fractions or old.temperature != new.temperature) - ) - ): - logger.info( - "Mixture update: rebuild expansion; fill fractions, EOS temperature " - "or parameterisation changed" - ) - return None - positions = np.asarray(expansion.z_span) - if source.medium is not None and updated.medium is not None: - paths = [(source.medium.parameter_paths, updated.medium.parameter_paths)] - else: - paths = [ - (before.parameter_paths, after.parameter_paths) - for before, after in zip(source.modes, updated.modes, strict=True) - if isinstance(before, ParameterisedMode) - and isinstance(after, ParameterisedMode) - ] - for before_paths, after_paths in paths: - for name, path in before_paths.items(): - if name in expansion.parameters: - continue - other = after_paths.get(name) - if ( - other is None - or not other.is_constant - or not np.array_equal(path(positions), other(positions)) - ): - logger.info( - "Mixture update: rebuild expansion; fixed parameter %s changed", - name, - ) - return None - return expansion - - -def _mixture_reuse( - source: Simulation, - updated: Simulation, - old: Mixture, - new: Mixture, - problem: PropagationProblem, -) -> TranslationReuse: - """Collect eligible immutable translation data after a material-path update.""" - before = { - response.species or old.labels[0]: response - for response in source.responses - if isinstance(response, RamanResponse) - } - after = { - response.species or new.labels[0]: response - for response in updated.responses - if isinstance(response, RamanResponse) - } - kernels = {} - for leaf in problem.responses.raman_terms: - if _undamped_model_key(before[leaf.label]) == _undamped_model_key( - after[leaf.label] - ): - kernels[leaf.label] = leaf.undamped - else: - logger.info("Mixture update: rebuild %s undamped Raman model", leaf.label) - return TranslationReuse( - problem.transform, - problem.initial, - _same_operator_function( - source, updated, old, new, problem.linear.phi_expansion - ), - _same_operator_function( - source, updated, old, new, problem.responses.prefactor_expansion - ), - kernels, - transverse_of(problem.route), - ) - - -def _undamped_model_key(response: RamanResponse) -> object: - """Describe only the inputs to the undamped Raman samples.""" - models = [] - for term in response.tensor_terms: - model = term.model - record = serialise(model) - if isinstance(model, (DampedOscillator, RotationalLines)): - assert isinstance(record, dict) - record.pop("dephasing", None) - models.append((term.contraction, record)) - return models - - -def _check_wall_declaration( - wall: CoherentWall | None, - *, - model: MarcatiliModel, - loss: LossSpec, - lattice: TubeLattice | None, - taper_coupling: bool = True, -) -> None: - """Refuse, at the keyword the caller typed, a wall this run cannot carry. - - The highest of the coherent wall's doors, and the only one that can name the - argument rather than the object it became: the backend selection, the loss - setting the block would replace, the capillary model it is not derived for and - the structured cladding it does not describe. Each is refused again further - down -- in :func:`solprop.modes.capillary.wall_operator` and at the backend -- - so nothing depends on this one being reached. - - - ``wall``: what was declared. ``None`` leaves every other argument alone. - """ - if wall is None: - return - if not taper_coupling: - msg = ( - "taper_coupling=False declines the taper connection, and a coherent wall " - "block always carries it: its basis is attached to the local radius. " - "Leave taper_coupling at its default with wall=, or pass wall=None for " - "the adiabatic route" - ) - raise DriverError( - msg, parameter=("wall", "taper_coupling"), rule="wall-keeps-taper-coupling" - ) - if loss is not True: - msg = ( - f"a coherent wall block is the loss model, and this run declares " - f"loss={loss!r}: the condensed Robin boundary condition replaces the " - "Marcatili attenuation of every channel, so a LossSpec beside it would " - "be discarded in silence. Pass loss=True with wall=, or leave wall=None " - "and keep the uncoupled loss you asked for" - ) - raise DriverError(msg, parameter=("wall", "loss"), rule="wall-is-the-loss") - if model != "full": - msg = ( - f"a coherent wall block needs one capillary model, the full one, and " - f"this run declares model={model!r}: the condensation inverts the full " - "model's transverse eigenvalue, and the reduced model's leading-order " - "carrier beside it would be overridden rather than propagated" - ) - raise DriverError(msg, parameter=("wall", "model"), rule="wall-full-model") - if lattice is not None: - msg = ( - "a coherent wall block assumes one uniform dielectric wall at a single " - "radius, and this run declares a tube lattice: an antiresonant guide's " - "loss is its tubes' resonances, not a Robin condition on a bare " - "capillary. Leave wall=None and keep the Vincetti loss, or drop the " - "lattice" - ) - raise DriverError(msg, parameter=("wall", "lattice"), rule="wall-no-lattice") - - -# Positions along the capillary at which the modes' group delays are read. -_WALKOFF_SAMPLES: Final = 33 - - -def _warn_adiabatic_taper(modes: Sequence[Mode]) -> None: - """Warn, once per translation, that a moving multimode set omits its connection. - - A mode set whose transverse shape changes along z -- a capillary or a fibre on a - taper -- couples its channels through the moving basis, ````, and - the diagonal route propagates each channel adiabatically with that term absent. A - single channel needs no connection, and a guide whose shape does not move -- a - constant bore, or a pressure gradient in a capillary, whose modes depend on the - radius alone -- has none. The coherent wall route carries the connection and never - reaches here, and neither does a run that declined it (``taper_coupling=False``); a - capillary's contiguous ``HE_1m`` set carries it by default - (:func:`solprop.modes.capillary.taper_coupling_block`), so what warns is a moving - set the coupling is not derived for. - """ - channels = tuple(modes) - if len(channels) < 2: - return - moving = [ - index - for index, mode in enumerate(channels) - if not getattr(mode, "radius_is_constant", mode.z_constant) - ] - if not moving or taper_coupling_block(channels) is not None: - return - warnings.warn( - f"{len(channels)} channels propagate on a guide whose modes change shape " - f"along z (channel {moving[0]} first), and this route omits the connection " - " between them: the inter-mode conversion a taper drives is " - "absent and each channel follows its local mode adiabatically. A " - "capillary's contiguous HE_11 to HE_1N set carries it by default, with or " - "without wall=CoherentWall(); this set is not that block " - "(docs/guide/waveguides/taper-coupling.md)", - AdiabaticTaperWarning, - stacklevel=3, - ) - - -_PACKAGE_FILES: Final = (str(Path(__file__).resolve().parents[1]),) -"""Frames a fill warning skips, so that it names the caller's line, not solprop's.""" - - -def _flow_radius( - modes: int | Sequence[Mode], pressure: Pressure | None -) -> ZProfile | None: - """Return the core radius a supplied mode set's gas flows through, if circular. - - Every mode that declares ``core_radius`` -- every ``MarcatiliMode`` subclass: a - capillary, a Vincetti mode, a tabulated hollow mode and a ``TabulatedMode`` built - from arrays -- is taken as a circular bore of that declared radius, which sets the - Poiseuille conductance and the choke (for a tube lattice the gaps between the tubes - are ignored). A rectangular guide's duct resistance and a slab's flow between plates - are not modelled: they keep the uniform law, and warn if their widths or gap vary - under a pressure gradient. - - - Returns: the first mode's core radius in m, or ``None`` for a guide without one. - """ - if isinstance(modes, int) or not modes: - return None - first = modes[0] - radius = getattr(first, "core_radius", None) - if isinstance(radius, ZProfile): - return radius - widths = [getattr(first, name, None) for name in ("width_x", "width_y")] - varying = any(isinstance(w, ZProfile) and not w.is_constant for w in widths) - if varying and isinstance(pressure, Sequence): - # A slab declares only width_x, its half-gap: flow between plates goes as - # a^3 per unit width, which is not modelled either. - message = ( - "the slab's gap varies along z, and its pressure gradient follows the " - "uniform law: flow between plates is not modelled, so the fill ignores " - "the gap" - if widths[1] is None - else "the rectangular guide's widths vary along z, and its pressure " - "gradient follows the uniform law: a rectangular duct's hydraulic " - "resistance is not modelled, so the fill ignores the widths" - ) - warnings.warn( - message, - GasFlowWarning, - stacklevel=2, - skip_file_prefixes=_PACKAGE_FILES, - ) - return None - - -def _warn_boreless_gradient(mixture: Mixture, radius: ZProfile | None) -> None: - """Warn if a gradient built without the bore fills a guide whose radius varies. - - A :class:`~solprop.profiles.PressureGradient` supplied directly, or inside a - Mixture, was built by its caller; if it does not know the radius, its ``p^2`` is - linear in ``z`` although a taper's is linear in ``int a^-4 dz``. - - - ``radius``: the guide's core radius in m, if it has one. - """ - shared = mixture.shared - if ( - radius is None - or radius.is_constant - or not isinstance(shared, PressureGradient) - or shared.is_constant - or shared.radius is not None - ): - return - warnings.warn( - "the pressure gradient was built without the bore, and the guide's core " - "radius varies along z: its p^2 is linear in z, whereas a taper's steady flow " - "makes p^2 linear in the hydraulic resistance int a^-4 dz, so a narrow waist " - "carries most of the drop. Build it with PressureGradient.from_stages(..., " - "radius=) or solprop.gasflow.steady_fill, or pass the end pressures as a pair " - "and let the constructor build the fill", - GasFlowWarning, - stacklevel=2, - skip_file_prefixes=_PACKAGE_FILES, - ) - - -def _warn_capillary_walkoff( - modes: Sequence[Mode], grid: TimeGrid, boundary: Absorber -) -> None: - """Warn if the mode set's group-delay walk-off would survive the temporal collar. - - A mode set shares one co-moving frame, so the group delay between its modes is - physics the operator carries: mode ``p`` drifts against the first by the integral of - ``beta_1,p - beta_1,1`` along the fibre, 43 fs per metre between HE_11 and HE_12 of - a 125 um capillary. The span of the set, integrated over the length with the modes' - own paths, is what :func:`solprop.boundaries.warn_walkoff` needs. It is read at the - reference frequency alone: a band-wide span would be dominated by band edges - carrying nothing. - - - ``boundary``: the collar the run will use, for the length that sets its temporal - strength. A spectral-only collar has no temporal part to check. - """ - if len(modes) < 2 or not np.any(boundary.temporal_rate): - return - frame = Frame.comoving(grid) - positions = np.linspace(0.0, grid.length, _WALKOFF_SAMPLES) - delays = np.array( - [ - [frame_quantities(mode, frame, float(z))[1] for mode in modes] - for z in positions - ] - ) - spread = np.max(delays, axis=1) - np.min(delays, axis=1) - span = float(np.trapezoid(spread, positions)) - warn_walkoff(grid, span, boundary_length=boundary.in_stage_length) - - -def _check_initial_position( - simulation: Simulation, settings: SimulationSettings -) -> None: - """Validate an absolute launch before translation can normalize its field. - - - ``simulation``: declared optical input, grid and absolute launch position in m. - """ - z = simulation.initial_z - if ( - not np.isfinite(z) - or z < 0 - or z >= simulation.grid.length - or np.any(np.asarray(settings.save_planes) <= z) - ): - raise DriverError( - "initial_z must be finite, nonnegative and before every save plane" - ) - if z == 0: - return - if ( - not isinstance(simulation.initial, np.ndarray) - or simulation.spatial_grid is not None - ): - raise DriverError("an interior launch requires an explicit modal spectrum") - if ( - simulation.noise is not None and simulation.noise.model == "modified" - ) or settings.schedule is not None: - raise DriverError( - "an interior launch refuses the modified noise model and a fixed schedule; " - "input noise takes its draw at the launch (SEP 37)" - ) - - -def _validate_resume( - problem: PropagationProblem, - request: RunRequest, - state: ResumeState, - execution: TDSEExecution | None, -) -> int: - """Validate restart position and budgets before preparing atomic resources. - - - ``request``: its run's request, with the absolute save positions in m. - - ``execution``: current atomic budget, if this is a TDSE run. - - Returns: index of the next scheduled save strictly beyond the accepted position. - """ - planes = request.observation.save_planes - next_plane = int(np.searchsorted(planes, state.plane.z, side="right")) - if state.checkpoint is not None and state.checkpoint.next_save_index != next_plane: - raise DriverError( - "checkpoint next_save_index disagrees with its absolute position" - ) - if not problem.initial.z <= state.plane.z <= planes[-1]: - raise DriverError( - "resume position lies outside the declared propagation interval" - ) - if state.plane.field is None: - if _work_left(request, state, next_plane): - msg = ( - f"the run's last saved plane, at z = {state.plane.z:.6e} m, kept no " - "field (it was saved with fields='none' or 'planes') and the file " - "holds no terminal checkpoint, which always carries the field, so " - "there is no state to continue from: a run stopped by an error or a " - "signal writes one, a run killed outright does not. Rerun it, or keep " - "the field on its last plane" - ) - raise DriverError(msg) - elif not np.all(np.isfinite(state.plane.field)): - raise DriverError("resume requires a finite stored field") - outcome = state.outcome - if tdse_descriptor(problem.route) is not None and ( - outcome is None or not outcome.logical_calls or not outcome.atomic_calls - ): - raise DriverError("a TDSE resume requires persisted cumulative call ledgers") - method = request.integration.method - if outcome is not None and outcome.logical_calls and isinstance(method, Multistep): - calls = CallLedger.from_record(outcome.logical_calls) - if method.max_calls is not None and calls.total > method.max_calls: - raise DriverError("logical call budget is below previously charged work") - if outcome is not None and outcome.atomic_calls: - atomic = AtomicCallLedger.from_record(outcome.atomic_calls) - if tdse_descriptor(problem.route) is None or execution is None: - raise DriverError( - "an atomic call ledger requires an explicit TDSE execution" - ) - if ( - execution.max_atomic_calls is not None - and atomic.attempted > execution.max_atomic_calls - ): - raise DriverError("atomic call budget is below previously attempted work") - return next_plane - - -def _check_tdse_archive( - archive: object, problem: PropagationProblem, handler: Output -) -> None: - """Refuse an archive request this run cannot honour, before any work. - - - ``archive``: the request, checked here to be a ``TDSEArchive`` or None. - """ - if archive is None: - return - if not isinstance(archive, TDSEArchive): - raise DriverError("tdse_archive must be a TDSEArchive or None") - if tdse_descriptor(problem.route) is None: - raise DriverError( - "tdse_archive archives a TDSE response's provider evaluations; this run " - "has no TDSE response" - ) - if isinstance(handler, HDF5Output) and _same_file(archive.path, handler.path): - raise DriverError( - f"the TDSE archive {archive.path} must differ from the optical HDF5 file" - ) - - -def _same_file(first: Path, second: Path) -> bool: - """Return whether two paths name the same file, existing or not. - - - Returns: True when they resolve to one location. - """ - if first.exists() and second.exists(): - return first.samefile(second) - return first.resolve() == second.resolve() - - -def _recorded_archive_uuid(state: ResumeState) -> str | None: - """Return the archive run UUID a resumed optical file records, if any. - - The latest execution's record wins, then the closing records of the earlier - executions, latest first: an archive first requested by a resume is recorded - in that execution's outcome, not in the file's original record. A TDSE resume - always has an outcome (:func:`_validate_resume`). - - - Returns: the UUID, or None when no execution of the run archived. - """ - outcome = state.outcome - # `_validate_resume` has refused a TDSE file without its ledgers and outcome. - assert outcome is not None - records: list[object] = [outcome.execution] - records.extend( - previous.get("execution") for previous in reversed(outcome.previous_executions) - ) - for record in records: - tdse = cast("Mapping[str, object]", record).get("tdse") - archive = tdse.get("archive") if isinstance(tdse, Mapping) else None - value = ( - cast("Mapping[str, object]", archive).get("run_uuid") - if isinstance(archive, Mapping) - else None - ) - if isinstance(value, str): - return value - return None - - -def _open_tdse_archive( # noqa: PLR0917 - called positionally - archive: TDSEArchive | None, - problem: PropagationProblem, - handler: Output, - state: ResumeState | None, - metadata: RunMetadata, - execution: TDSEExecution, -) -> TDSEArchiveWriter | None: - """Open the run's archive session, continuing its own run's archive on a resume. - - - ``state``: what a resumed file holds, or None for a fresh run. - - Returns: the open session, or None without a request. - """ - descriptor = tdse_descriptor(problem.route) - if archive is None or descriptor is None: - return None - recorded = None if state is None else _recorded_archive_uuid(state) - weights = problem.transform.node_weights - context: dict[str, object] = { - "descriptor": json.loads(descriptor.configuration_json), - "descriptor_identity": descriptor.identity, - "model": descriptor.model, - "n_nodes": problem.transform.n_nodes, - "node_weights_m2": None if weights is None else weights.tolist(), - "quadrature": metadata.quadrature, - "execution": dataclasses.asdict(execution), - "provider_pin": tdse_runtime.pinned_revision(), - # The file's own record on a resume, which the session holds and a snapshot - # compares with. - "optical_configuration_hash": ( - metadata if state is None else state.metadata - ).configuration_hash(), - "versions": dict(metadata.versions), - } - return TDSEArchiveWriter.open( - archive, - run_uuid=str(uuid.uuid4()) if recorded is None else recorded, - kind="run" if state is None else "resume", - optical=handler.path if isinstance(handler, HDF5Output) else None, - context=context, - resume=recorded is not None, - ) - - -def _close_run( - backend: Backend | None, - binding: TDSEBinding | None, - archive: TDSEArchiveWriter | None, - handler: Output, - failure: BaseException | None, -) -> None: - """Close a run's resources in order, each even when an earlier close fails. - - The backend before the atomic binding, the binding before the archive that - records it, and the optical output last. - - - ``failure``: the error the run is raising, if any. - """ - closing = failure - for resource in (backend, binding, archive, handler): - if resource is None: - continue - try: - resource.close() - except BaseException as error: - if closing is None: - closing = error - else: - closing.add_note(f"a run resource could not be closed: {error}") - if failure is None and closing is not None: - raise closing - - -def _close_tdse_archive( - archive: TDSEArchiveWriter | None, failure: BaseException | None -) -> None: - """Close the archive session, never masking the run's own error. - - - ``failure``: the error the run is raising, if any. - """ - if archive is None: - return - try: - archive.close() - except TDSEArchiveError as error: - if failure is None: - raise - failure.add_note(f"the TDSE archive could not be closed: {error}") - logger.exception("could not close the TDSE archive") - - -def _resolve_tdse_grid( - grid: TimeGrid, responses: Sequence[object], settings: SimulationSettings -) -> tuple[TimeGrid, SimulationSettings]: - """Resolve the response lattice once, before building the optical input. - - - Returns: resolved grid and settings with a matching fine temporal collar. - """ - atomic = [one for one in responses if isinstance(one, TDSEGasResponse)] - if not atomic: - if settings.tdse_execution is not None: - raise DriverError("tdse_execution requires a TDSEGasResponse") - return grid, settings - if len(atomic) != 1 or len(responses) != 1: - raise DriverError("TDSE owns the complete electronic response") - if settings.tdse_execution is None: - raise DriverError("TDSE requires explicit tdse_execution settings") - refined = refine_response_grid(grid, atomic[0].sampling) - return refined, _refined_tdse_collar(settings, grid, refined) - - -def _check_tdse_execution(simulation: Simulation, settings: SimulationSettings) -> None: - """Refuse unsupported execution choices before any atomic preparation.""" - atomic = any(isinstance(one, TDSEGasResponse) for one in simulation.responses) - if not atomic: - if settings.tdse_execution is not None: - raise DriverError("tdse_execution requires a TDSEGasResponse") - return - if ( - settings.tdse_execution is None - or settings.backend != "numpy_ref" - or not isinstance(settings.integrator, Multistep) - ): - raise DriverError( - "TDSE requires explicit NumPy, Multistep and tdse_execution settings" - ) - if simulation.noise is not None and simulation.noise.model == "modified": - raise DriverError("TDSE refuses noise='modified'; use 'input' or None") - if settings.schedule is not None or settings.interpolate_planes: - raise DriverError("TDSE requires adaptive Adams with landed save planes") - if _scheme(settings) != "nonlinear": - raise DriverError("TDSE requires the in-stage temporal collar") - if ( - simulation.spatial_grid is not None - or simulation.general is not None - or simulation.wall is not None - ): - raise DriverError("TDSE requires the uncoupled modal capillary route") - - -def _refined_tdse_collar( - settings: SimulationSettings, parent: TimeGrid, refined: TimeGrid -) -> SimulationSettings: - """Resample only a recognized analytic collar on the finer response lattice. - - Each rate is rebuilt from its own length (SEP 22 section 5.4): the spectral rate - and the band from the reference length, the temporal rates from the collar's - in-stage length, or as zeros for a spectral-only collar. - - - ``settings``: original absorber definition and its physical lengths in m. - """ - boundary = settings.absorber - if boundary is None or boundary.temporal_rate_fine.shape == (refined.n_to,): - return settings - original = collar(parent, boundary_length=boundary.reference_length) - length = boundary.in_stage_length - - def temporal(grid: TimeGrid, axis: np.ndarray) -> np.ndarray: - """Return the collar's temporal rate on a time axis of a grid, 1/m. - - - ``axis``: its coarse or fine time axis, s. - - Returns: the bounded profile at the in-stage length, or zeros for a - spectral-only collar. - """ - if not boundary.temporal: - return np.zeros(axis.size) - return temporal_collar_rate(axis, grid.t_collar, length) - - expected = { - "spectral_rate": original.spectral_rate, - "band_mask": original.band_mask, - "temporal_rate": temporal(parent, parent.t), - "temporal_rate_fine": temporal(parent, parent.t_fine), - } - if not all( - np.array_equal(getattr(boundary, name), value) - for name, value in expected.items() - ): - raise DriverError( - "TDSE refinement requires a standard analytic collar or a custom " - "absorber already sampled on the refined response grid" - ) - return dataclasses.replace( - settings, - absorber=dataclasses.replace( - boundary, temporal_rate_fine=temporal(refined, refined.t_fine) - ), - ) - - -def _replaced_launch( - original: PropagationProblem, - field0: Complex[np.ndarray, "n_modes n_pol n_omega"], - launch_z: float, -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return a replaced input as the derived problem's launch, without a further copy. - - On a free-space or spatial route the launch is a view of the derived simulation's - own copy; on a modal route it is the power conversion of that copy, a second array - beside it. - - - ``field0``: the replacement input, in true sqrt(W) on a modal route. - - ``launch_z``: where it is launched, m. - """ - if original.transform.geometry is not None: - return _state(field0, original.shape[-1]) - # The conversion is a new array, held as it is rather than copied again. - converted = power_amplitude( - field0, power_weight_at(original, launch_z), inverse=True - ) - return _hold(np.asarray(converted, dtype=np.complex128)) - - -def _check_input_identity( - identity: str | None, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray | CouplingLaunch, -) -> None: - """Refuse a stated identity that is empty, or of anything but an array input.""" - if identity is None: - return - if not identity: - raise DriverError("input_identity must say what the input is: it is empty") - if not isinstance(initial, np.ndarray): - msg = ( - "input_identity states an array input's identity; " - f"a {type(initial).__name__} is recorded as itself" - ) - raise DriverError(msg) - - -def _given_input( - given: np.ndarray, held: Complex[np.ndarray, "n_modes n_pol n_omega"] -) -> np.ndarray: - """Return an array input as the caller gave it, for the record: a read-only view. - - The record keeps the caller's dtype and values, so that a file written from such - an input before the simulation copied it still resumes; a view of a read-only owner - cannot be made writeable, so the record and the run cannot be changed through it. - - - Returns: a read-only view of `held` when `given` is complex128, otherwise of a - held copy of `given` in its own dtype; :class:`~solprop.translate.problem.Initial` - takes either as held. - """ - # The whole dtype, byte order included: the record serialises its exact string. - source = held if given.dtype == held.dtype else _held_block(given, given.dtype) - return _hold(source.view()) - - -def _check_planar_input(shape: tuple[int, ...], n_columns: int, n_omega: int) -> None: - """Refuse a planar input whose shape is not ``(n_p*n_y, 1, n_omega)``. - - - ``shape``: the input's shape. - """ - if shape != (n_columns, 1, n_omega): - msg = ( - "a planar input has shape (n_p*n_y, 1, n_omega) = " - f"{(n_columns, 1, n_omega)}, got {shape}" - ) - raise DriverError(msg) - - -def _check_spatial_input(shape: tuple[int, ...], n_columns: int, n_omega: int) -> None: - """Refuse a spatial input whose shape is not ``(n_columns, n_pol, n_omega)``. - - - ``shape``: the input's shape. - """ - if len(shape) != 3 or shape[0] != n_columns or shape[2] != n_omega: - msg = "spatial input must have shape (n_columns, n_pol, n_omega)" - raise DriverError(msg) - - -def _built( - initial: Pulse | np.ndarray, grid: TimeGrid -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return the initial spectrum, building a pulse against the grid when given one. - - - Returns: the spectrum, complex128, shape ``(n_modes, n_pol, n_omega)``, read-only - and the simulation's own: a copy of an array (of its repeated block for a - broadcast), or a solprop pulse's new build. - """ - if isinstance(initial, np.ndarray): - # Copied now, so that changing the caller's array later changes nothing, as - # the problem holds it: a broadcast copies its block alone, and an array a - # simulation already holds (its own, passed back) is taken as it is. - return _held_state(initial) - # A runtime-checkable protocol is satisfied by the presence of `build`, not by its - # being callable, so the guard below is still the one that names the mistake. - build = getattr(initial, "build", None) - if not callable(build): - msg = ( - f"the input must be a pulse with a build(grid) method or a built spectrum, " - f"got {type(initial).__name__}" - ) - raise DriverError(msg) - return _built_state(initial, build(grid)) - - -def _built_state( - pulse: object, built: object -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return a pulse's built spectrum as the problem holds it, copied only if foreign. - - A build of solprop's own pulse classes is a new array solprop made, so it is held - as it is; any other pulse's build may be an array the pulse keeps, and is copied - (:class:`~solprop.translate.problem.Initial`). - - - Returns: the spectrum, complex128, read-only and held. - """ - spectrum = np.asarray(built) - build = getattr(type(pulse), "build", None) - own = getattr(build, "__module__", None) == _PULSE_MODULE - if own and spectrum.dtype == np.complex128: - return _hold(spectrum) - return _held_state(spectrum) - - -_PULSE_MODULE: Final = SpatialPulse.__module__ -"""The module of solprop's pulse classes, whose ``build`` always makes a new array.""" - - -def _interpolated(state: ResumeState) -> bool: - """Return whether the plane a file resumes from was interpolated inside a step. - - A landed plane is the end of an accepted step of the run; an interpolated one lies - inside a step. The entrance is the initial field. - """ - return state.interpolated - - -def _open_output( - output: Output | Path | str | None, - metadata: RunMetadata, - *, - resume: _ResumeChoice, -) -> tuple[Output, ResumeState | None]: - """Choose the handler a run writes to, and read what a resumed file holds. - - The resume gate (ADR-066): the file's configuration hash must equal `metadata`'s. - Whether work is left is decided by :func:`_continues`. - - - Returns: the handler, and the state to resume from or ``None``. - """ - if resume and metadata.configuration.get("_benchmark_plasma_source"): - raise DriverError("gated plasma benchmark output cannot be resumed") - if isinstance(output, Path | str): - path = Path(output) - if resume and check_cache(path, metadata): - return HDF5Output(path, append=True), resume_state(path) - return HDF5Output(path), None - if resume: - msg = ( - "only a path can be resumed: the file has to be opened in append mode, " - "which is the driver's to decide; pass output= with resume=True" - ) - raise DriverError(msg) - return (MemoryOutput() if output is None else output), None - - -def _check_resume_choice(resume: object) -> None: - """Refuse a ``resume`` value that is not one of its three. - - A mistyped word would otherwise read as ``True``. - """ - if resume is True or resume is False or resume == "interpolated": - return - msg = f"resume must be False, True or 'interpolated', got {resume!r}" - raise ValueError(msg) - - -def _work_left(request: RunRequest, state: ResumeState, next_plane: int) -> bool: - """Return whether a file has anything left to compute, by position. - - Decided by position, not status: a file killed after its last plane has no work - left, though its status reads ``running``; a terminal checkpoint still owes its - plane. - """ - return ( - state.checkpoint is not None - or next_plane < request.observation.save_planes.size - ) - - -def _refuse_interpolated(path: Path, state: ResumeState, resume: _ResumeChoice) -> None: - """Refuse to continue from a plane interpolated inside a step, unless accepted. - - An interpolated plane is not a state the march passed through, so a run restarted - there is not the run it continues (SEP 14 section 5); ``resume="interpolated"`` - accepts that. - """ - if resume == "interpolated" or not _interpolated(state): - return - msg = ( - f"{path} ends on a plane interpolated inside a step at z = " - f"{state.plane.z:.6e} m, not a state the march passed through; " - "pass resume='interpolated' to continue afresh from it, or rerun" - ) - raise DriverError(msg) - - -def _continues( - path: Path, - request: RunRequest, - state: ResumeState, - next_plane: int, - resume: _ResumeChoice, -) -> bool: - """Say whether a resume of a file computes anything, once its hash has passed. - - - Returns: ``True`` when work is left, and ``False`` for a finished file. - """ - if not _work_left(request, state, next_plane): - return False - _refuse_interpolated(path, state, resume) - return True - - -def _gnlse_grid( - grid: TimeGrid | None, - length: float, - wavelength: float, - wavelength_limits: tuple[float, float] | None, - trange: float | None, -) -> TimeGrid: - """Return the envelope grid a GNLSE run propagates on. - - - ``grid``: a grid given by the caller, or ``None`` to build one. - - ``length``: fibre length, m. - - ``wavelength``: reference wavelength, m. - - ``wavelength_limits``: window limits, m. - - ``trange``: time window, s. - """ - if grid is not None: - if grid.kind != "envelope": - msg = ( - f"prop_gnlse propagates an envelope grid and this one is a " - f"{grid.kind} grid; the GNLSE mapping is the envelope Kerr response " - "(docs/guide/waveguides/gnlse.md), and a field-resolved run is " - "prop_capillary's " - "path" - ) - raise DriverError(msg, parameter="grid", rule="gnlse-envelope-grid") - return grid - if wavelength_limits is None or trange is None: - msg = ( - "give wavelength_limits and trange so a grid can be built, or a grid of " - "your own" - ) - raise DriverError( - msg, - parameter=_unset(wavelength_limits=wavelength_limits, trange=trange), - rule="grid-or-limits", - ) - with _about(renamed=_GRID_KEYWORDS): - return EnvelopeGrid.from_limits(length, wavelength, wavelength_limits, trange) - - -def _gnlse_fibre( - gamma: float | None, - betas: Sequence[float] | np.ndarray | None, - beta: object, - grid: TimeGrid, - *, - wavelength: float, - loss: float | None, - shock: bool, - fibre: Mode | IndexTable | None = None, - n2: float | str | None = None, - raman: bool = False, - raman_fraction: float | None = None, - selection: MaterialSelection | None = None, -) -> GnlseProblem: - """Map one dispersion source and one nonlinear coefficient onto the GNLSE. - - - ``gamma``: nonlinear coefficient in 1/(W m), absent beside `n2`. - - ``betas``: Taylor coefficients about the reference frequency, s^n/m. - - ``beta``: a pair of frequency and beta arrays, a callable, or None. - - ``wavelength``: positive reference wavelength, m. - - ``loss``: power loss in dB/m; None means omitted. Refused beside `fibre`. - - ``fibre``: whole dispersion and loss, including a positive propagation area for - `n2`. - - ``n2``: nonlinear index in m^2/W, or ``"recommended"`` under a `selection`; - requiring `fibre`. - - ``raman_fraction``: share of the nonlinearity held back from the Kerr term, - between zero and one; None resolves as in - :func:`solprop.nonlinear.gnlse.problem_from`. - """ - if sum(value is not None for value in (betas, beta, fibre)) != 1: - raise DriverError( - "give exactly one of betas, beta and fibre", - parameter=("betas", "beta", "fibre"), - rule="betas-beta-or-fibre", - ) - if (gamma is None) == (n2 is None): - raise DriverError( - "give exactly one of gamma and n2", - parameter=("gamma", "n2"), - rule="gamma-or-n2", - ) - if n2 is not None and fibre is None: - raise DriverError( - "n2 requires fibre with an effective area", - parameter=("n2", "fibre"), - rule="n2-needs-fibre-area", - ) - if fibre is not None and loss is not None: - raise DriverError( - "the fibre carries its loss; scale it on the mode", - parameter=("fibre", "loss_db_per_m"), - rule="fibre-carries-loss", - ) - omega_ref = 2.0 * np.pi * C / wavelength - mode = ( - None - if fibre is None - else _gnlse_mode(fibre, wavelength, needs_area=n2 is not None) - ) - area = 1.0 if mode is None else mode.aeff() - # The GNLSE mapping of ADR-026 (docs/dev/decisions.md) is unchanged. An externally - # specified fibre replaces the placeholder mode, so its dispersion never enters the - # unit prefactor. Its own names are the keywords' but for the area, the fibre's. - with _about(renamed={"aeff": "fibre"}): - mapping = problem_from( - np.asarray([0.0] if betas is None else betas, dtype=np.float64), - omega_ref=omega_ref, - gamma=gamma, - n2=n2, - aeff=area, - loss_db_per_m=0.0 if loss is None else loss, - shock=shock, - raman=raman, - raman_fraction=raman_fraction, - selection=selection, - ) - if mode is not None: - return dataclasses.replace(mapping, mode=mode) - if betas is not None: - return mapping - nodes, values = _dispersion_table(beta, grid) - with _about("beta"): - table = SpecifiedMode.from_table( - nodes, - values, - loss=attenuation_from_db(0.0 if loss is None else loss), - aeff=mapping.aeff, - ) - return dataclasses.replace(mapping, mode=table) - - -def _gnlse_mode( - fibre: Mode | IndexTable, wavelength: float, *, needs_area: bool -) -> Mode: - """Resolve a supplied table or mode with its propagation area at the pump. - - - ``wavelength``: positive reference wavelength, m: where a table's area column and - a provider channel's surrogate area are evaluated. - """ - if isinstance(fibre, IndexTable): - if needs_area and fibre.area is None: - raise DriverError( - "n2 requires a fibre table with an effective-area column", - parameter=("n2", "fibre"), - rule="n2-needs-fibre-area", - ) - area = ( - 1.0 - if fibre.area is None - else float( - fibre.effective_area(np.array([2.0 * np.pi * C / wavelength]))[0] - ) - ) - with _about("fibre"): - return SpecifiedMode.from_index_table(fibre, aeff=area) - if not math.isfinite(fibre.aeff(0.0)) or fibre.aeff(0.0) <= 0: - raise DriverError( - "fibre effective area must be positive and finite", parameter="fibre" - ) - # aeff=None hands over the fibre's own declared area path when it has one and - # samples the source's own area along z when its whole geometry moves; the - # frozen callables forward the position, so the dispersion and the area move - # together and a fibre moving along z needs no refusal (ADR-026, - # docs/dev/decisions.md). The wavelength is where a provider channel's area is - # solved, as a table's is read at it above; every other mode's area is not a - # function of frequency and ignores it. - with _about("fibre"): - return SpecifiedMode.from_mode(fibre, wavelength=wavelength) - - -def _dispersion_table( - beta: object, grid: TimeGrid -) -> tuple[Float[np.ndarray, " n_nodes"], Float[np.ndarray, " n_beta_nodes"]]: - """Return the ``(omega, beta)`` table a caller's dispersion describes. - - A callable is sampled on the grid's **active band**, sorted and made unique, because - that is exactly where the linear operator evaluates a mode - (:func:`solprop.linear.dispersion_operator`) and a spline refuses to extrapolate - beyond its data. - - - Returns: the frequencies in rad/s and the propagation constant in 1/m. - """ - if callable(beta): - nodes = np.unique(grid.omega[grid.active_mask]) - return nodes, np.asarray(beta(nodes), dtype=np.float64) - if isinstance(beta, tuple) and len(beta) == 2: - nodes, values = beta - return ( - np.asarray(nodes, dtype=np.float64), - np.asarray(values, dtype=np.float64), - ) - msg = ( - f"beta must be a (omega, beta) pair of arrays or a callable of omega, got " - f"{type(beta).__name__}" - ) - raise DriverError(msg, parameter="beta") - - -def _shaped_pulse( - shape: str, - *, - wavelength: float, - fwhm: float | None, - width: float | None, - energy: float | None, - peak_power: float | None, - phases: Sequence[float] | np.ndarray, - pulses: Sequence[Pulse] | None = None, -) -> GaussianPulse | SechPulse | PulseSum: - """Return the shaped input pulse a keyword interface starts from. - - - ``wavelength``: centre wavelength, m, and the reference of the spectral phase. - - ``fwhm``: intensity full width at half maximum, s. - - ``width``: natural width of a ``sech**2``, s. - - ``energy``: pulse energy, J. - - ``peak_power``: peak power, W. - - ``phases``: Taylor coefficients of the optics-convention spectral phase. - """ - if pulses is not None: - given = [ - name - for name, value in ( - ("fwhm", fwhm), - ("width", width), - ("energy", energy), - ("peak_power", peak_power), - ) - if value is not None - ] - if len(phases): - given.append("phases") - if shape != "gauss": - given.append("shape") - if given: - msg = ( - f"pulses= describes every input pulse itself, and this call also sets " - f"{', '.join(given)}: give each pulse its own duration, energy and " - "phase, and leave the single-pulse keywords unset" - ) - raise DriverError( - msg, parameter=("pulses", *given), rule="pulses-or-pulse-keywords" - ) - with _about("pulses"): - return PulseSum(tuple(pulses)) - if shape == "sech": - # The pulse names its own arguments, which are the keywords but for phase. - with _about(renamed=_PULSE_KEYWORDS): - return SechPulse( - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phase=phases, - ) - if shape != "gauss": - msg = f"shape must be 'gauss' or 'sech', got {shape!r}" - raise DriverError(msg, parameter="shape") - if width is not None or fwhm is None: - msg = ( - "a Gaussian input is specified by its intensity fwhm; give fwhm= rather " - "than width=, which is the natural width of a sech**2 pulse" - ) - raise DriverError( - msg, - parameter="fwhm" if width is None else ("width", "shape"), - rule="gauss-fwhm", - ) - with _about(renamed=_PULSE_KEYWORDS): - return GaussianPulse( - wavelength=wavelength, - fwhm=fwhm, - energy=energy, - peak_power=peak_power, - phase=phases, - ) - - -def _save_planes( - length: float, - save_planes: Sequence[float] | np.ndarray | None, - n_save: int | None, -) -> Float[np.ndarray, " n_z"]: - """Return the save planes a keyword interface asks for. - - - ``length``: fibre length, m. - - ``save_planes``: positions given by the caller, m. - - Returns: the planes after ``z = 0``, m, shape ``(n_z,)``. - """ - if save_planes is not None: - if n_save is not None: - msg = "give save_planes or n_save, not both" - raise DriverError( - msg, parameter=("save_planes", "n_save"), rule="save-planes-or-n-save" - ) - return np.ascontiguousarray(save_planes, dtype=np.float64).reshape(-1) - count = DEFAULT_N_SAVE if n_save is None else n_save - if count < 2: - msg = ( - f"n_save counts the plane at z = 0, so it must be at least 2, got {count}; " - "a run always stores the entrance" - ) - raise DriverError(msg, parameter="n_save") - return np.linspace(0.0, length, count)[1:] - - -def _scheme(settings: SimulationSettings) -> AbsorberScheme: - """Return the collar scheme the settings resolve to.""" - return cast("AbsorberScheme", str(settings.absorber_scheme)) - - -def _spatial_settings(settings: SimulationSettings, route: str) -> SimulationSettings: - """Give a spatial run's settings the route's defaults (SEP 37, SEP 21). - - - ``route``: the interface they are for. - - Returns: `settings`, reading the route's row of - :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` for whatever the caller left: the - controller (:data:`~solprop.driver.defaults.SPATIAL_RTOL` for radial and planar - runs, :data:`~solprop.driver.defaults.FREE_RTOL` for Cartesian and line ones), the - integrator (``RungeKutta()`` in free space, whose Adams - history would be too large) and the collar scheme (``"split_step"`` unless planes - are interpolated). Settings that already belong to a route, this one or another - (settings copied from a modal run, for a comparison), keep what that route - resolved and are returned as they are. - """ - if settings._route is not None: - return settings - return dataclasses.replace(settings, _route=route) - - -def _with_boundary( - settings: SimulationSettings, - grid: TimeGrid, - absorber: Absorber | Literal[False] | None, - scheme: AbsorberScheme | None, -) -> SimulationSettings: - """Apply a spatial interface's absorber keywords to its settings (SEP 21 section 5). - - - ``absorber``: the collar given; ``False`` zeroes the temporal and spectral rates - of the settings' absorber and keeps its lengths (without one it builds the hard - mask alone); ``None`` leaves the settings' own. The transverse boundary is - ``spatial_absorber``'s (SEP 40). - - ``scheme``: the temporal scheme given; ``None`` leaves the settings' own. - """ - changes: dict[str, object] = {} - if absorber is False: - if scheme is not None: - msg = ( - "absorber=False switches the temporal collar off, so there is nothing " - f"for absorber_scheme={scheme!r} to apply; give one or the other" - ) - raise DriverError(msg) - current = settings.absorber - changes["absorber"] = ( - no_absorber(grid) - if current is None - else dataclasses.replace( - current, - spectral_rate=np.zeros_like(current.spectral_rate), - user_rate=None, - temporal_rate=np.zeros(grid.n_t), - temporal_rate_fine=np.zeros(grid.n_to), - temporal=False, - ) - ) - elif absorber is not None: - if settings.absorber is not None: - msg = ( - "the settings already declare an absorber; give it there or as the " - "absorber keyword, not both" - ) - raise DriverError(msg) - changes["absorber"] = absorber - if scheme is not None: - if settings.absorber_scheme_named and settings.absorber_scheme != scheme: - msg = ( - f"the settings already declare absorber_scheme=" - f"{settings.absorber_scheme!r}; give it there or as the keyword" - ) - raise DriverError(msg) - changes["absorber_scheme"] = scheme - return dataclasses.replace(settings, **changes) if changes else settings - - -def _watched( - settings: SimulationSettings, check: BoundaryCheck | None -) -> SimulationSettings: - """Apply a spatial constructor's ``boundary_check`` keyword to its settings.""" - if check is DEFAULT_BOUNDARY_CHECK or check == settings.boundary_check: - return settings - if settings.boundary_check != DEFAULT_BOUNDARY_CHECK: - msg = ( - "the settings already declare a boundary_check; give it there or as the " - "boundary_check keyword, not both" - ) - raise DriverError(msg) - return dataclasses.replace(settings, boundary_check=check) - - -def _rechecked( - simulation: Simulation, - original: PropagationProblem, - derived: PropagationProblem, - planes: Float[np.ndarray, " n_z"], -) -> SpatialAbsorberDesign | None: - """Return a derived run's transverse design, its check rerun on the new launch. - - SEP 40 section 5.2: a derived run keeps its parent's design, which its shared - operator and transform were built with, but not its verdict, which belongs to a - launch. Only a scalar multiple of the checked launch reuses it, because the check - is linear and normalised by the launch's energy. A new launch whose rms - wavenumber lies above the one the band was derived from warns as well. - - - ``planes``: the derived run's save planes, m. - """ - design = transverse_of(original.route) - if design is None or not design.has_collar: - return design - space = simulation.spatial_grid - medium = simulation.medium - assert isinstance(space, RadialGrid | FreeGrid | PlanarGrid) - assert medium is not None - old = np.asarray(original.initial.field0) - new = np.asarray(derived.initial.field0) - reference = float(np.vdot(old, old).real) - if reference > 0.0: - scale = np.vdot(old, new) / reference - residual = float(np.linalg.norm(new - scale * old)) - if residual <= 1e-12 * float(np.linalg.norm(new)): - return design - length = float(np.max(planes)) - float(derived.initial.z) - dispersion = SpatialDispersion( - medium, simulation.grid, Frame.comoving(simulation.grid) - ) - if design.specification.band_source == "launch": - before = resolve_transverse( - design.specification, space, dispersion, old, length - ) - after_kappa = _launch_band(design.specification, space, dispersion, new, length) - if after_kappa is not None and after_kappa > max(before.kappa_p) * (1 + 1e-9): - warnings.warn( - "the new launch spreads beyond the band its run's collar was designed " - f"for ({after_kappa:.3g} > {max(before.kappa_p):.3g} rad/m): what it " - "carries above that band is absorbed only partly; translate afresh", - BoundaryWarning, - stacklevel=3, - ) - try: - return checked_design( - dataclasses.replace(design, check={}), space, dispersion, new, length - ) - except TranslationError as error: - msg = ( - f"{error}. The grid is shared with the parent run, so translate a new " - "simulation with that collar_fraction for this launch" - ) - raise DriverError(msg) from error - - -def _launch_band( - specification: SpatialAbsorber, - space: RadialGrid | FreeGrid | PlanarGrid, - dispersion: SpatialDispersion, - launch: np.ndarray, - length: float, -) -> float | None: - """Return the widest protected band a launch would derive, or ``None``. - - - ``launch``: the launch, canonical layout. - - ``length``: the run's length, m. - - Returns: the widest axis's band edge, rad/m; ``None`` when it cannot be resolved. - """ - try: - return max( - resolve_transverse(specification, space, dispersion, launch, length).kappa_p - ) - except TranslationError: - return None - - -def _refuse_pending_check(problem: PropagationProblem) -> None: - """Refuse to start a run whose collar's design check never saw a launch. - - A coupled child is translated before its launch exists; its check runs when - :meth:`Simulation.with_input` gives it one (SEP 40 section 5.2). - """ - design = transverse_of(problem.route) - if design is not None and design.check.get("status") == "pending": - msg = ( - "this run's collar has not been checked against a launch: give it one " - "with with_input before running it (SEP 40 section 5.2)" - ) - raise DriverError(msg) - - -def _bounded( - settings: SimulationSettings, - spatial_absorber: SpatialAbsorber | Literal[False] | None, -) -> SimulationSettings: - """Apply a spatial constructor's ``spatial_absorber`` keyword to its settings. - - - ``spatial_absorber``: the keyword's value; ``None`` leaves the settings' own. - """ - if spatial_absorber is None or spatial_absorber == settings.spatial_absorber: - return settings - if settings.spatial_absorber is not None: - msg = ( - "the settings already declare a spatial_absorber; give it there or as " - "the spatial_absorber keyword, not both" - ) - raise DriverError(msg) - return dataclasses.replace(settings, spatial_absorber=spatial_absorber) - - -def _shot_noise( - noise: str | None, grid: TimeGrid, seed: int | None, *, n_modes: int = 1 -) -> NoiseRealisation | None: - """Draw the shot-noise realisation a run carries. - - The model is a plain string rather than :data:`NoiseModel` for the reason - :func:`solprop.fourier._check_kind` gives: this is the run-time guard for callers - that ignore the annotation, and the test session's ``beartype`` hook would otherwise - reject the bad value before the check below is reached. - - - ``noise``: which model to use, or ``None`` for a noiseless run. - - ``seed``: entropy of the generator; ``None`` draws from the operating system, and - the entropy it drew is recorded on the realisation either way. - """ - if noise is None: - return None - if noise not in ("input", "modified"): - msg = f"noise must be 'default', None, 'input' or 'modified', got {noise!r}" - raise DriverError(msg, parameter="noise") - generator = np.random.default_rng(seed) - draw = input_noise if noise == "input" else modified_noise - return draw(grid, generator, n_modes=n_modes) - - -def _general_noise( - noise: str | None, - grid: TimeGrid, - seed: int | None, - guide: ModalGuide, -) -> NoiseRealisation | None: - """Draw a general modal run's shot noise, in the basis it is defined in. - - The shot-noise draw (``docs/guide/media/noise.md``) is one photon per channel per - bin, which is an energy-normalised true-power amplitude only in a basis orthonormal - in true power. A guide in the provider's own coordinates **is** that basis, so - this is :func:`_shot_noise` unchanged. A unitary change of basis draws - **once, in the reference basis, with the same seed and the same stream**, and - carries that one realisation into the run's coordinates through the inverse - transform (:func:`solprop.noise.transformed_noise`): a second draw in the declared - basis would be a different stochastic model, because a fixed-modulus random-phase - draw is not invariant in distribution under unitary mixing. - - - ``noise``: which model to use, or ``None`` for a noiseless run. - """ - drawn = _shot_noise(noise, grid, seed, n_modes=len(guide.mode_collection)) - sampling = guide.sampling - if drawn is None or sampling is None or sampling.basis is None: - return drawn - return transformed_noise(drawn, sampling.basis.transform) - - -def _spatial_shot_noise( - noise: NoiseModel, flux: SpatialFlux, n_pol: int, seed: int | None -) -> NoiseRealisation: - """Draw a radial or Cartesian run's shot noise at its entrance plane. - - - ``seed``: entropy of the generator; ``None`` draws from the operating system. - """ - return spatial_noise(flux, np.random.default_rng(seed), model=noise, n_pol=n_pol) - - -def _spatial_draw( - noise: NoiseModel, simulation: Simulation, medium: SpatialMedium -) -> Callable[[int | None], NoiseRealisation | None]: - """Return the draw of a spatial run's shot noise, measured at its entrance plane.""" - flux = SpatialFlux.resolve( - simulation._base_grid, - cast("RadialGrid | FreeGrid | PlanarGrid", simulation.spatial_grid), - medium, - z=simulation.initial_z, - ) - return functools.partial( - _spatial_shot_noise, noise, flux, simulation._field0.shape[1] - ) - - -def _with_noise( - problem: PropagationProblem, noise: NoiseRealisation | None -) -> PropagationProblem: - """Return `problem` carrying `noise`, every other leaf shared by identity. - - A realisation reaches a translation only through the initial record and the - ``has_noise`` flag; what else consults it is a refusal keyed on the model, and a - replacement that changes the model is checked by its caller. - - - ``noise``: the realisation in true sqrt(W) on a modal route, or ``None`` for no - noise. - - Returns: the problem with the realisation in its internal, vacuum-normalised - amplitude. - """ - noise0 = None if noise is None else noise_spectrum(noise, problem.shape) - if noise0 is not None and problem.transform.geometry is None: - noise0 = power_amplitude( - noise0, power_weight_at(problem, problem.initial.z), inverse=True - ) - return dataclasses.replace( - problem, - initial=dataclasses.replace( - problem.initial, - noise0=noise0, - noise_model="none" if noise is None else noise.model, - ), - flags=dataclasses.replace(problem.flags, has_noise=noise0 is not None), - ) - - -def _redrawable( - simulation: Simulation, - draw: Callable[[int | None], NoiseRealisation | None], - seed: int | None, -) -> Simulation: - """Let a realisation drawn without a seed be drawn again from a chosen one. - - A call with ``seed=None`` records the operating-system entropy it drew, so the - same call made twice describes two runs. Keeping the draw lets a resume take the - seed its file recorded and a scan point take its own (:meth:`Simulation._reseed`); - a seeded realisation keeps the one it was given. - - - Returns: `simulation`, able to redraw its noise when `seed` was ``None``. - """ - if seed is None and simulation.noise is not None: - simulation._noise_redraw = draw - return simulation - - -def default_radial_order(n_modes: int) -> int: - """Return the radial order of the fixed rule a capillary run uses by default. - - The rule has to integrate the *quartic* of the mode profiles, because the Kerr - polarisation is cubic in the field and its projection multiplies by one more - profile. Those are Bessel functions of ``r/a``, so a Gauss rule converges spectrally - and the only question is where. Two measurements set the number: the quartic overlap - tensor of four HE_1m modes is converged to 2e-14 at ``n_r = 32`` - (``docs/guide/waveguides/mode-sets.md``), and the embedded error estimate for three - modes falls 1.6e-1, 2.1e-2, 4.1e-5, 7.2e-9, 1.2e-15 at ``n_r = 8, 16, 24, 32, 48``. - Thus 32 clears the alarm by two orders on three modes and is the reference's - rounding on four. Eight nodes per mode carries that scaling to larger sets, - since the highest radial order in the quartic grows with the set. - - A flat, larger order whatever the mode count would be safer and slower; 32 nodes on - the sets a capillary run actually uses, with the alarm watching, is the trade this - makes. The number rests on these two measurements and has not been calibrated - further. - - Parameters - ---------- - n_modes : int - How many modes the set carries, >= 1. - - Returns - ------- - int - Radial nodes asked of - :func:`solprop.modes.quadrature.transverse_quadrature`, which with - ``radial_rule="gauss_kronrod"`` rounds an even count up by one so that the - rule is the Kronrod extension of the half-order Gauss rule. - - Examples - -------- - >>> from solprop.driver.simulation import default_radial_order - >>> default_radial_order(1), default_radial_order(4), default_radial_order(8) - (32, 32, 64) - """ - return max(_MINIMUM_RADIAL_ORDER, _RADIAL_NODES_PER_MODE * n_modes) - - -def default_field_range(peak_field: float) -> tuple[float, float]: - """Return the field amplitudes an ionisation table covers, from the input's peak. - - The table is built in ``log`` field and its knots double until it is converged - (:func:`solprop.translate.ionisation.build_rate_table`), so a generous range costs - little; getting it wrong is what costs. Below ``field_min`` the rate is taken as - zero, which is right because every rate this package carries is exactly zero below - its threshold, and the threshold is where the lower limit ends up anyway. Above - ``field_max`` the table *holds its last value*, so an upper limit that is too low - is a silent clip of the rate exactly where ionisation matters most. - - Hence three decades below the input peak and thirty times above it. The lower - factor only has to reach under the rate's threshold, which for the noble gases at - the intensities a capillary reaches sits one to two decades below the peak. The - upper factor has to clear the self-compressed peak: a capillary compressing a pump - to a few optical cycles raises the on-axis field by a small single-digit factor - before ionisation-induced defocusing and loss clamp it, so thirty is roughly an - order of margin on that -- a thousand times in intensity. - - Parameters - ---------- - peak_field : float - Peak field amplitude of the input, V/m, > 0: on a capillary the largest - over the axis and the transverse nodes. - - Returns - ------- - tuple of float - Lower and upper field amplitude in V/m. - - Raises - ------ - DriverError - If the peak field is not positive, which means the input is empty. - - Examples - -------- - >>> from solprop.driver.simulation import default_field_range - >>> low, high = default_field_range(1e10) - >>> f"{low:.3g} {high:.3g}" - '1e+07 3e+11' - """ - if not peak_field > 0.0: - msg = ( - f"the input carries no field (peak {peak_field:.3e} V/m), so " - "the ionisation table has no range to cover; give " - "ionisation_field_range explicitly" - ) - raise DriverError(msg) - return (peak_field * _FIELD_RANGE_BELOW, peak_field * _FIELD_RANGE_ABOVE) - - -def _capillary_grid( - grid: TimeGrid | None, - length: float, - wavelength: float, - wavelength_limits: tuple[float, float] | None, - trange: float | None, - *, - envelope: bool, - thg: bool, -) -> TimeGrid: - """Return the grid a capillary run propagates on. - - - ``length``: capillary length, m. - - ``wavelength``: reference wavelength, m. - - ``wavelength_limits``: window limits, m. - - ``trange``: requested time window, s. - """ - if grid is not None: - if wavelength_limits is not None or trange is not None: - msg = ( - "give a grid or the wavelength_limits and trange to build one, not " - "both: the keywords would be ignored" - ) - given = {"wavelength_limits": wavelength_limits, "trange": trange} - raise DriverError( - msg, - parameter=( - "grid", - *(name for name, value in given.items() if value is not None), - ), - rule="grid-or-limits", - ) - return grid - if wavelength_limits is None or trange is None: - msg = ( - "a capillary run needs wavelength_limits and trange to build its grid, " - "or a grid of your own" - ) - raise DriverError( - msg, - parameter=_unset(wavelength_limits=wavelength_limits, trange=trange), - rule="grid-or-limits", - ) - with _about(renamed=_GRID_KEYWORDS): - if envelope: - return EnvelopeGrid.from_limits( - length, - wavelength, - wavelength_limits, - trange, - thg=thg, - ) - return FieldGrid.from_limits(length, wavelength, wavelength_limits, trange) - - -def _capillary_modes( - modes: int | Sequence[Mode], - radius: float | ZProfile, - mixture: Mixture, - *, - length: float, - lattice: TubeLattice | None = None, - model: MarcatiliModel, - loss: LossSpec, - cladding: GlassIndex | complex | None, -) -> ModeCollection: - """Return the mode set under the declared capillary material. - - - ``radius``: core radius in m, used when constructing a numbered mode set. - - ``length``: the run's length in m, over which a supplied set's radius is compared. - """ - if isinstance(modes, int) and modes < 1: - raise DriverError( - f"a capillary mode set needs at least one mode, got {modes}", - parameter="modes", - ) - if lattice is not None: - if not isinstance(modes, int): - raise DriverError( - "lattice is refused: the given modes carry their own geometry", - parameter=("lattice", "modes"), - rule="lattice-builds-modes", - ) - if model == "reduced": - raise DriverError( - 'lattice is incompatible with model="reduced"', - parameter=("lattice", "model"), - rule="lattice-full-model", - ) - return ModeCollection.vincetti( - radius, - count=modes, - mixture=mixture, - lattice=lattice, - loss=loss, - cladding=cladding, - ) - if isinstance(modes, int): - return ModeCollection( - tuple( - MarcatiliMode( - radius, - mixture=mixture, - m=index + 1, - model=model, - loss=loss, - cladding=cladding, - ) - for index in range(modes) - ) - ) - if not modes: - # The capillary wording, which the int branch above already uses: the - # waveguide entry has its own. - raise DriverError( - "a capillary mode set needs at least one mode", parameter="modes" - ) - given = _given_gas_modes(modes, mixture) - _refuse_ignored_geometry( - given, radius, length=length, model=model, loss=loss, cladding=cladding - ) - return given - - -# Two radius paths agree when they agree to this relative tolerance at every sampled -# position: a supplied set and a declared radius built from the same number or the -# same profile agree exactly, so this only forgives the last bits of a radius -# computed two ways, and a real disagreement is a percent or more. -_RADIUS_RTOL = 1e-12 - - -def _refuse_ignored_geometry( - modes: Sequence[Mode], - radius: float | ZProfile, - *, - length: float, - model: MarcatiliModel, - loss: LossSpec, - cladding: GlassIndex | complex | None, -) -> None: - """Refuse, by name, a declared capillary argument a supplied mode set ignores. - - A supplied set is the run's geometry: its modes keep their own radius, model, loss - and cladding and are only rebound to the declared fill, so an argument that - disagrees with them would be silently discarded. The radius is always declared -- it - is positional -- and is compared with each mode's own ``core_radius`` path over the - run, breakpoints included, so a taper beside a constant bore, or a different bore, - is named. `model`, `loss` and `cladding` are compared only when they differ from - their defaults, which is the only way this layer can tell a declaration from an - omission: a default beside a mode that says otherwise was not asked for. - - - ``modes``: the supplied set, non-empty. - - ``radius``: the declared core radius, m. - - ``length``: the run's length, m, over which the radius paths are compared. - """ - declared = as_profile(radius) - for index, mode in enumerate(modes): - own = getattr(mode, "core_radius", None) - if own is None: - msg = ( - "from_capillary's radius is the capillary's bore, and supplied mode " - f"{index} ({type(mode).__name__}) declares no core radius to compare " - "it with, so the radius would be ignored: propagate a mode set that " - "is not a capillary's with from_waveguide or prop_waveguide" - ) - raise DriverError(msg, parameter=("radius", f"modes[{index}]")) - if not _same_radius(declared, own, length): - msg = ( - f"from_capillary's radius disagrees with supplied mode {index} " - f"({mode.channel_label.display_label}): a supplied mode set is the " - "run's geometry and keeps its own core radius, so the declared one " - "would be ignored. Declare the modes' own radius (the same number or " - "profile) or build the modes on the radius wanted" - ) - raise DriverError(msg, parameter=("radius", f"modes[{index}]")) - _refuse_ignored_setting(index, mode, "model", model, default="full") - _refuse_ignored_setting(index, mode, "loss", loss, default=True) - _refuse_ignored_setting(index, mode, "cladding", cladding, default=None) - - -def _refuse_ignored_setting( - index: int, mode: Mode, name: str, declared: object, *, default: object -) -> None: - """Refuse a non-default capillary setting that a supplied mode does not carry. - - - ``name``: the keyword, which is also the mode's attribute: ``"model"``, ``"loss"`` - or ``"cladding"``. - """ - if declared is default or declared == default: - return - own = getattr(mode, name, None) - same = own is declared or ( - not callable(own) and not callable(declared) and own == declared - ) - if not same: - msg = ( - f"from_capillary's {name}={declared!r} disagrees with supplied mode " - f"{index}, which carries {name}={own!r}: a supplied mode set keeps its " - f"own {name}, so the declaration would be ignored. Declare it on the " - f"modes, or leave {name} at its default beside them" - ) - raise DriverError(msg, parameter=(name, f"modes[{index}]")) - - -def _same_radius(declared: ZProfile, own: ZProfile, length: float) -> bool: - """Return whether two radius paths agree over a run of `length`. - - - ``declared``: the declared core radius, m. - - ``own``: a supplied mode's core radius, m. - - ``length``: the run's length, m. - - Returns: whether they agree to ``_RADIUS_RTOL`` at 65 equally spaced positions, - both paths' breakpoints and the midpoints between all of them. - """ - if declared is own: - return True - start = max(0.0, declared.z_span[0], own.z_span[0]) - end = min(length, declared.z_span[1], own.z_span[1]) - edges = np.unique( - np.concatenate( - [np.linspace(start, end, 65), declared.breakpoints, own.breakpoints] - ).clip(start, end) - ) - positions = np.unique(np.concatenate([edges, 0.5 * (edges[:-1] + edges[1:])])) - return bool( - np.allclose(own(positions), declared(positions), rtol=_RADIUS_RTOL, atol=0.0) - ) - - -def _given_gas_modes(modes: Sequence[Mode], mixture: Mixture) -> ModeCollection: - """Bind supplied modes and retain the existing orthonormality gate.""" - if not modes: - raise DriverError( - "a waveguide mode set needs at least one mode", parameter="modes" - ) - rebound: list[Mode] = [] - for index, mode in enumerate(modes): - with _about(f"modes[{index}]"): - rebound.append(_rebind_mode(mode, mixture)) - given = ModeCollection(tuple(rebound)) - if len(given) > 1: - overlaps = given.orthonormality() - worst = float(np.max(np.abs(overlaps - np.eye(len(given))))) - if worst > ORTHONORMALITY_TOLERANCE: - raise DriverError( - f"the given modes are not an orthonormal set: the largest deviation " - f"of the overlap matrix from the identity is {worst:.3e}, above " - f"{ORTHONORMALITY_TOLERANCE:.0e}. One synthesis matrix and its dual " - "represent an orthonormal basis and nothing else", - parameter="modes", - ) - return given - - -_NODE_MAXIMUM_PLASMA = ( - "electron_density", - "peak_ionisation_rate", - "species_electron_density", - "species_peak_ionisation_rate", -) -"""The plasma statistics read at a sampled node rather than on the axis.""" - -_SAMPLED_PEAK_THRESHOLD = 0.999 -"""Innermost field scale below which a node maximum warns (SEP 19, Q8). - -At 0.999 the innermost node sees 99.8% of the axial intensity, so a rate of order 10 -there is within 2% of its axial value; every Gauss-Kronrod and Gauss rule in r/a of -eight or more nodes is closer still.""" - - -def _warn_sampled_peak(rule: TransverseQuadrature | None, spec: StatsSpec) -> None: - """Warn once per run when a node maximum is read far from the axis (SEP 19, Q8). - - A field-scale Gauss rule of few nodes integrates the source but has no node near - the axis, so a node-maximum plasma statistic samples the axial peak at its - innermost node, which a steep rate under-reports. The value is unchanged; the - warning names the node and the on-axis statistics that read the axis. - """ - if rule is None or rule.radial_rule != "field_scale": - return - asked = sorted(set(_NODE_MAXIMUM_PLASMA) & {*spec.names, *spec.periodic}) - if not asked: - return - eta = float(j0(jn_zeros(0, 1)[0] * float(np.min(rule.xi_r)))) - if eta < _SAMPLED_PEAK_THRESHOLD: - warnings.warn( - f"the field-scale rule's innermost node is at eta = {eta:.3f}, " - f"{eta**2:.1%} of the axial intensity, so {asked} are node maxima " - f"there: a rate of order K reads about eta^(2K) of its axial value. " - "Request the on_axis_* statistics for the axis, or use the Radau rule, " - "whose first node is on it", - SampledPeakWarning, - stacklevel=3, - ) - - -def _capillary_quadrature( - modes: ModeCollection, - n_r: int | None, - n_theta: int | None, - *, - radial_rule: RadialRule, -) -> TransverseQuadrature: - """Return the fixed transverse rule of a capillary run. - - - ``n_r``: radial nodes; ``None`` takes the module's - :func:`~solprop.driver.simulation.default_radial_order`. - - ``n_theta``: azimuthal nodes; ``None`` takes the rule's own default where one is - needed. - """ - if modes.modes[0].dimlimits(0.0)[0] == "slab": - if n_theta is not None: - msg = ( - f"n_theta={n_theta} was given for a slab set: the planar guide is " - "uniform in y, so its rule has one y node of unit width and n_r " - "alone is the x node count" - ) - raise DriverError(msg, parameter="n_theta") - # The measured bound of the x rule's quartic products is the Cartesian - # per-axis one; the default takes whichever is larger. - needed = cartesian_nodes(max(mode.azimuthal_order for mode in modes.modes)) - order = max(default_radial_order(len(modes)), needed) if n_r is None else n_r - return transverse_quadrature(modes.modes, n_r=order, radial_rule=radial_rule) - if n_theta is not None and not modes.needs_full_rule: - msg = ( - f"n_theta={n_theta} was given for a set whose modes all have azimuthal " - "order zero: |J_0(u r/a)| does not depend on the angle, so the rule is " - "radial and every azimuthal node is a copy of the same integrand" - ) - raise DriverError(msg, parameter="n_theta") - order = default_radial_order(len(modes)) if n_r is None else n_r - if modes.modes[0].dimlimits(0.0)[0] == "cartesian": - # For a Cartesian set n_r and n_theta are the x and y node counts, so - # both defaults take the measured aliasing bound of the set's highest - # transverse order rather than the radial and azimuthal defaults, which - # under-resolve y from order two upwards. - needed = cartesian_nodes(max(mode.azimuthal_order for mode in modes.modes)) - return transverse_quadrature( - modes.modes, - n_r=max(order, needed) if n_r is None else n_r, - n_theta=needed if n_theta is None else n_theta, - radial_rule=radial_rule, - ) - if n_theta is None: - return transverse_quadrature(modes.modes, n_r=order, radial_rule=radial_rule) - return transverse_quadrature( - modes.modes, n_r=order, n_theta=n_theta, radial_rule=radial_rule - ) - - -def _no_quadrature( - modes: ModeCollection, - n_r: int | None, - n_theta: int | None, - *, - radial_rule: RadialRule, -) -> None: - """Check a mode-averaged hollow run and return its absent transverse rule. - - A mode-averaged run carries one mode as a uniform disc of its effective area, the - one-node transform (:func:`solprop.translate.modal.mode_averaged_transform`), so it - has no rule for the keywords that shape one to act on. - - - ``modes``: the mode set, which must hold exactly one mode. - - ``n_r``: radial nodes, which must not be given. - - ``n_theta``: azimuthal nodes, which must not be given. - - ``radial_rule``: the radial rule family, which must be the default. - """ - if len(modes) != 1: - msg = ( - f"mode_average=True needs exactly one mode, and the set has {len(modes)}: " - "energy moving between modes is what a mode-averaged run cannot carry " - "(docs/guide/waveguides/mode-averaging.md)" - ) - raise DriverError( - msg, parameter=("mode_average", "modes"), rule="mode-average-one-mode" - ) - given = [ - name - for name, value, unset in ( - ("n_r", n_r, None), - ("n_theta", n_theta, None), - ("radial_rule", radial_rule, "gauss_kronrod"), - ) - if value != unset - ] - if given: - msg = ( - f"{', '.join(given)} given beside mode_average=True: they shape a " - "transverse rule, and a mode-averaged run has none, its mode being a " - "uniform disc of the effective area" - ) - raise DriverError( - msg, parameter=("mode_average", *given), rule="mode-average-no-quadrature" - ) - - -def _capillary_responses( - gas: Gas | str, - material: Species, - *, - kerr: bool, - plasma: bool | IonisationRate, - preionisation: float | ZProfile, - integration: IntegrationPolicy | None, - wavelength: float, - envelope: bool, - electronic: KerrResponse | DispersiveKerrResponse | None, - selection: MaterialSelection, -) -> list[object]: - """Return the Kerr and plasma responses of a single-gas capillary run. - - The Raman response, optmat's, is the fill's (:func:`_optmat_raman`). - - - ``material``: the fill's species as resolved under the run's selection, which the - plasma response carries: it is never rebuilt from the name with the default - selection, so the material manifest lists only the models that acted (Q45). - - ``preionisation``: fraction already ionised before the pulse, a number or a path - along z in m. - - ``integration``: the field plasma column's running-integral rule; ``None`` takes - the response's default. - - ``wavelength``: reference wavelength, m, the rate's carrier unless the selection - names one. - - ``electronic``: the gas's electronic response under the selection's policy - (:func:`solprop.nonlinear.mixtures.electronic_response`); ``None`` without one. - """ - responses: list[object] = [] - if kerr: - # Per particle: the density path supplies the rest (ADR-026, - # docs/dev/decisions.md). - assert electronic is not None - responses.append(electronic) - if plasma is not False: - rate = _ionisation_rate(gas, plasma, wavelength, selection) - if envelope: - responses.append( - EnvelopePlasmaResponse(material, rate, preionisation=preionisation) - ) - else: - chosen: dict[str, IntegrationPolicy] = ( - {} if integration is None else {"integration": integration} - ) - responses.append( - PlasmaResponse(material, rate, preionisation=preionisation, **chosen) - ) - if not responses: - msg = ( - "a capillary run with neither kerr nor plasma has no nonlinearity; use " - "the linear propagator instead of a solver" - ) - raise DriverError( - msg, parameter=("kerr", "plasma"), rule="capillary-needs-nonlinearity" - ) - return responses - - -def _default_raman(mixture: Mixture, *, provisional: bool = False) -> bool: - """Decide what ``raman=None`` means for a fill. - - On when any component carries a Raman response: a built-in gas whose optmat - response is curated (:func:`solprop.nonlinear.optmat_raman.raman_status`), or a - custom species carrying a model with at least one term. An atom contributes - nothing and says nothing. A molecule whose optmat response is provisional (N2O's - bands, SEP 3, D13) is off and warns rather than silently going without: a user - expecting every molecule's Raman response would otherwise not learn that nitrous - oxide is the one curated only provisionally. Under - ``MaterialSelection(raman="provisional")`` it takes that response instead (Q17), - which warns once per run with its status and is recorded. - """ - on = False - for component in mixture.components: - material = component.species - if not built_in_population(material): - model = material.raman - on = on or ( - model is not None - and (model.rotational is not None or model.vibrational is not None) - ) - continue - status = raman_status(material.name) - if status == "validated" or (status == "provisional" and provisional): - on = True - elif status == "provisional": - warnings.warn( - SolpropWarning( - f"{component.label} ({material.name}) is a molecule whose Raman " - "response optmat has only provisionally, so it is off; pass " - "raman=False to say so, or MaterialSelection(raman='provisional') " - "to admit it", - parameter="raman", - ), - stacklevel=3, - ) - return on - - -def _optmat_raman( # noqa: PLR0917 - called positionally - gas: Gas | str, - mixture: Mixture, - selection: MaterialSelection, - thg: bool, - wavelength: float, - length: float, -) -> list[object]: - """Return a single-gas fill's Raman response from optmat (SEP 3). - - - ``wavelength``: the grid reference wavelength in m, the carrier unless the - selection names one. - - ``length``: the run's length in m. - """ - logger.debug("optmat Raman response of the %s fill", gas) - response = mixture_raman( - mixture, - True, - selection=selection, - thg=thg, - wavelength=wavelength, - length=length, - explicit=True, - ) - return [] if response is None else [response] - - -def _ionisation_rate( - gas: Gas | str, - plasma: bool | IonisationRate, - wavelength: float, - selection: MaterialSelection, -) -> IonisationRate: - """Return the ionisation rate a capillary run uses. - - ``plasma=True`` takes optmat's rate model - (:func:`solprop.nonlinear.optmat_rates.selected_rate`, SEP 3, D1); an explicit - rate object passes through unchanged. The models' physical limits are discussed - in ``docs/guide/media/ionisation.md``. - - - ``wavelength``: reference wavelength, m, the rate's carrier unless the selection - names one. - """ - if isinstance(plasma, bool): - return selected_rate(species(gas, selection=selection), selection, wavelength) - return plasma - - -def _with_integration_statistics( - spec: StatsSpec, responses: Sequence[object] -) -> StatsSpec: - """Add the plasma integration diagnostics to a default spec under the band rule. - - The undershoot, work defect and boundary level of SEP 18 are recorded by default - wherever the field plasma column integrates by the band rule, so the run's warning - can read them back; a run on the plain trapezoid records none of them. - """ - if not any( - isinstance(one, PlasmaResponse) and one.integration == "band" - for one in responses - ): - return spec - extra = tuple( - name for name in PLASMA_INTEGRATION_STATISTICS if name not in spec.names - ) - return dataclasses.replace(spec, names=(*spec.names, *extra)) - - -@dataclasses.dataclass(frozen=True) -class _TDSEEntry: - """Hold a TDSE capillary run's resolved response, resources and defaults. + Take the fill from optmat under this selection: a gas or fraction mapping + of built-in gases, ``"air"`` and ``Gas.Air`` resolve through + :func:`solprop.physdata.species` and + :meth:`solprop.physdata.mixtures.Mixture.air` with it. The supplied modes, + their claddings, a supplied mixture or susceptibility, and a provider's + material regions are taken as they are. The run records its material + manifest. ``None`` (the default) is + ``MaterialSelection()``. - Attributes - ---------- - response : TDSEGasResponse - The declared response, its atomic window's duration resolved. - execution : TDSEExecution - Atomic runtime resources. - backend : str - The resolved backend, ``numpy_ref``. - step_planes : int - Resolved natural-step save interval. - n_save : int or None - Resolved requested-plane count; 2 (the entrance and the end) when neither - `save_planes` nor `n_save` was given. - record : dict - The resolved defaults, recorded in the run's configuration. - """ + Returns + ------- + Simulation + A configured simulation, ready to translate or run. - response: TDSEGasResponse - execution: TDSEExecution - backend: str - step_planes: int - n_save: int | None - record: dict[str, JsonValue] - - -def _tdse_entry( - tdse: TDSEGasResponse | None, - execution: TDSEExecution | None, - *, - trange: float | None, - kerr: bool, - mode_average: bool, - unset: Mapping[str, object], - backend: str, - step_planes: int | None, - save_planes: Sequence[float] | np.ndarray | None, - n_save: int | None, -) -> _TDSEEntry | None: - """Resolve the ``tdse=`` capillary keywords (SEP 20 sections 5.2 and 5.8). - - - ``tdse``: the declared response, or None for a rate-based run. - - ``trange``: requested time window in s, the default atomic-window duration. - - ``kerr``: the Kerr switch, which must stay true beside a TDSE response. - - ``mode_average``: the mode-averaged switch, which must stay false beside a TDSE - response: its translation needs the resolved radial quadrature. - - ``unset``: Electronic-response keywords that must be left unset beside it. - - ``save_planes``: requested save positions in m. - - Returns: the resolution, or None without `tdse`. - """ - if tdse is None: - if execution is not None: - raise DriverError( - "tdse_execution requires a TDSEGasResponse", - parameter=("tdse_execution", "tdse"), - rule="tdse-execution-needs-tdse", + Raises + ------ + DriverError + If the modes cannot bind a gas mixture, a gas keyword is given beside a + provider, a general-guide keyword is given without one, or any supplied + setting is invalid. + """ + if provider is not None: + return _from_general( + cls, + provider, + modes, + length, + gas=gas, + pressure=pressure, + pressure_floor=pressure_floor, + vacuum_end=vacuum_end, + channel=channel, + incident=incident, + coupling=coupling, + kerr_form=kerr_form, + observable_rtol=observable_rtol, + observable_atol=observable_atol, + wavelength=wavelength, + wavelength_limits=wavelength_limits, + trange=trange, + grid=grid, + envelope=envelope, + fwhm=fwhm, + width=width, + shape=shape, + energy=energy, + peak_power=peak_power, + phases=phases, + pulses=pulses, + launch=launch, + susceptibility=susceptibility, + temperature=temperature, + kerr=kerr, + thg=thg, + plasma=plasma, + preionisation=preionisation, + plasma_integration=plasma_integration, + raman=raman, + mode_average=mode_average, + n_r=n_r, + n_theta=n_theta, + radial_rule=radial_rule, + ionisation_field_range=ionisation_field_range, + absorber=absorber, + absorber_scheme=absorber_scheme, + boundary_check=boundary_check, + noise=noise, + seed=seed, + rtol=rtol, + atol=atol, + dz_max=dz_max, + dz_init=dz_init, + schedule=schedule, + integrator=integrator, + interpolate_planes=interpolate_planes, + step_planes=step_planes, + save_planes=save_planes, + n_save=n_save, + stats=stats, + polarisation_taper=polarisation_taper, + node_budget=node_budget, + backend=backend, + polarisation=polarisation, + taper_coupling=taper_coupling, + materials=MaterialSelection() if materials is None else materials, ) - return None - if not isinstance(tdse, TDSEGasResponse): - raise DriverError("tdse must be a TDSEGasResponse", parameter="tdse") - given = [name for name, value in unset.items() if value is not None] - if not kerr: - given.append("kerr=False") - if mode_average: - given.append("mode_average=True") - if given: - raise DriverError( - f"the TDSE response owns the electronic response; leave " - f"{', '.join(given)} unset beside tdse=", - parameter=("tdse", *(name.removesuffix("=False") for name in given)), - rule="tdse-owns-electronic", - ) - response = tdse - window = tdse.atomic_window - duration_source = None - if isinstance(window, TDSEAtomicWindow): - duration_source = "explicit" - if window.duration is None: - if trange is None: - raise DriverError( - "the atomic window's duration is unresolved: give trange or " - "TDSEAtomicWindow(duration=...)", - parameter=("tdse", "trange"), + for name, value in ( + ("channel", channel), + ("incident", incident), + ("coupling", coupling), + ("kerr_form", None if kerr_form == "real" else kerr_form), + ("observable_rtol", observable_rtol), + ("observable_atol", observable_atol), + ): + if value is not None: + msg = ( + f"{name}= belongs to the general modal route and needs a " + "provider= beside it; the gas-filled waveguide route launches " + "one pulse into its lowest mode and controls the step alone" ) - response = dataclasses.replace( - tdse, atomic_window=dataclasses.replace(window, duration=trange) - ) - duration_source = "trange" - resolved_backend = "numpy_ref" if backend == "auto" else backend - if backend == "auto": - logger.info("backend='auto' resolves to numpy_ref for a TDSE response") - final_only = save_planes is None and n_save is None - resolved_steps = 1 if step_planes is None else step_planes - record: dict[str, JsonValue] = { - "model": tdse.model, - "backend": {"requested": backend, "resolved": resolved_backend}, - "step_planes": {"requested": step_planes, "resolved": resolved_steps}, - "requested_planes": "final_only" if final_only else "explicit", - "atomic_window_duration": duration_source, - "execution": "default" if execution is None else "explicit", - } - return _TDSEEntry( - response=response, - execution=TDSEExecution() if execution is None else execution, - backend=resolved_backend, - step_planes=resolved_steps, - n_save=2 if final_only else n_save, - record=record, - ) - - -def _entry_overrides[P, R]( # noqa: PLR0917 - called positionally - atomic: _TDSEEntry | None, - plasma: P, - raman: R, - backend: str, - n_save: int | None, - step_planes: int | None, -) -> tuple[_TDSEEntry | None, P, R, str, int | None, int]: - """Return a TDSE entry with the capillary settings it overrides, or the originals. - - - ``step_planes``: requested natural-step interval; ``None`` is 0 without TDSE. - - Returns: the entry, the plasma and Raman switches, the backend, the plane count - and the step interval. - """ - if atomic is None: - steps = 0 if step_planes is None else step_planes - return None, plasma, raman, backend, n_save, steps - # False is a member of both switches' declared types. - off = cast("P", False), cast("R", False) - return atomic, *off, atomic.backend, atomic.n_save, atomic.step_planes - - -def _tdse_stats(spec: StatsSpec) -> StatsSpec: - """Remove the statistics a TDSE response cannot supply from a default spec.""" - return dataclasses.replace( - spec, - names=tuple( - name for name in spec.names if name not in TDSE_UNAVAILABLE_STATISTICS - ), - ) - - -def _warn_tdse_cost(quadrature: TransverseQuadrature | None) -> None: - """Warn once when a TDSE run integrates over a many-node radial rule.""" - if quadrature is None or quadrature.radial_rule in ( - "field_scale", - "field_scale_radau", - ): - return - warnings.warn( - f"the TDSE response runs on the {quadrature.radial_rule!r} radial rule with " - f"{quadrature.n_nodes} nodes; each node is one atom in every atomic call, so " - "the atomic cost is proportional to it. A field-scale rule " - "(radial_rule='field_scale', a few nodes) integrates the HE11 response at a " - "fraction of the cost (docs/guide/waveguides/radial-quadrature.md)", - TDSECostWarning, - stacklevel=3, - ) - - -def _capillary_stats( - *, with_plasma: bool, mixture: Mixture, averaged: bool = False -) -> StatsSpec: - """Return the statistics a capillary run records unless told otherwise. - - The multimode defaults with the z-profiles, plus every statistic only a backend can - compute: it holds the nodes the plasma was evaluated at, the mode set the axis - needs, and the embedded quadrature estimate a later pass cannot reconstruct. - - - ``averaged``: whether the run is mode-averaged: the mode-averaged defaults then - replace the multimode ones, and the quadrature estimate, which has no rule to - read, is left out. - """ - wanted = [ - name - for name in BACKEND_STATISTICS - if (with_plasma or name not in PLASMA_STATISTICS) - and not (averaged and name == "quadrature_error") - ] - base = default_spec( - "mode_averaged" if averaged else "multimode", with_profiles=True - ) - wanted.extend( - ( - "species_ionisation", - "species_electron_density", - "species_peak_ionisation_rate", - "species_density", - "species_ionisation_energy", - ) - ) - if mixture.shared is None: - base = dataclasses.replace( - base, names=tuple(name for name in base.names if name != "pressure") - ) - return dataclasses.replace(base, names=(*base.names, *wanted)) - - -_GAS_KEYWORD_DEFAULTS: Mapping[str, object] = { - "gas": None, - "pressure": None, - "pressure_floor": None, - "vacuum_end": None, - "susceptibility": None, - "temperature": None, - "kerr": True, - "thg": None, - "plasma": None, - "preionisation": None, - "plasma_integration": None, - "raman": None, - "mode_average": False, - "n_r": None, - "n_theta": None, - "radial_rule": "gauss_kronrod", - "ionisation_field_range": None, - "polarisation": None, - "envelope": False, - "node_budget": None, - "taper_coupling": True, -} -"""Every gas-filled `from_waveguide` keyword, with the value that means "not given". - -A general modal guide owns its materials, its responses, its transverse rule and -its grid kind, so each of these is refused by name when it departs from the value -here; a keyword silently ignored would let a caller believe a fill, a response or -a resolution was applied. -""" - -_GAS_KEYWORD_REASONS: Mapping[str, str] = { - "gas": "a guide's materials are its regions' own laws, not a fill", - "pressure": "a guide's materials are its regions' own laws, not a fill", - "pressure_floor": "a guide has no fill and so no pressure path", - "vacuum_end": "a guide has no fill and so no gas flow to choke", - "susceptibility": "a guide's dispersion is its provider's, solved from the " - "regions' permittivity laws", - "temperature": "a material region's law is a declared state, and a guide " - "carries no gas whose density a temperature would set", - "kerr": "a guide's Kerr response is its regions'; declare a region with no " - "MaterialKerr to make it linear", - "thg": "the general route's third harmonic is set by kerr_form=: the real " - "cubic carries it and the analytic-signal form does not", - "plasma": "a general guide carries the instantaneous Kerr term alone; " - "ionisation is not implemented on the general route", - "preionisation": "a general guide carries no plasma channel", - "plasma_integration": "a general guide carries no plasma column to integrate", - "raman": "a guide's delayed response is its material regions' own, the silica " - "tensor Raman model", - "mode_average": "a general guide's channels are resolved on its own node rule; " - "mode averaging is the gas-filled route's", - "n_r": "the transverse rule is the guide's own node rule, declared when the " - "guide was built", - "n_theta": "the transverse rule is the guide's own node rule, declared when " - "the guide was built", - "radial_rule": "the transverse rule is the guide's own node rule, declared when " - "the guide was built", - "taper_coupling": "a guide that moves along z carries its own connection in the " - "general route's flow; the opt-out is the capillary route's", - "ionisation_field_range": "a general guide carries no ionisation table", - "polarisation": "a general guide puts every physical channel, including each " - "parity partner, in its own row; launch into them by name with channel=, or " - "build the launch yourself and pass it to Simulation.with_input", - "envelope": "the general route synthesises an analytic field from a " - "carrier-resolved spectrum and propagates on a field grid", - "node_budget": "the general route sizes its node chunk from its own memory " - "ledger, which counts different buffers", -} -"""Why each gas-filled keyword cannot be carried by a general modal guide.""" - - -def _means_not_given(value: object, default: object) -> bool: - """Return whether a supplied keyword carries the value that means "not given". - - - Returns: True when the keyword was, in effect, not given. - """ - if value is default: - return True - if isinstance(value, np.ndarray) and value.ndim > 0: - # `array == default` is elementwise and its truth value raises; a keyword - # whose "not given" value is a scalar or None is never an array anyway, so - # an array is always something the caller meant. - return False - return bool(value == default) - - -def _refuse_gas_keywords(supplied: Mapping[str, object]) -> None: - """Refuse, by name, every gas-filled keyword given beside a provider.""" - for name, value in supplied.items(): - # Indexed, not `.get`: a keyword this table does not know is a keyword - # `from_waveguide` would silently ignore on this route, and - # `test_from_waveguide_refuses_or_names_every_keyword_it_takes` locks the - # two together so that adding one cannot pass unnoticed. - default = _GAS_KEYWORD_DEFAULTS[name] - if _means_not_given(value, default): - continue - msg = ( - f"{name}={value!r} beside a provider=: {_GAS_KEYWORD_REASONS[name]}. " - "The general modal route takes its physics from the guide " - "(docs/guide/waveguides/general-modal.md)" - ) - raise DriverError(msg) - - -def _general_stats() -> StatsSpec: - """Return the statistics a general modal run records by default. - - The route's **own** names, not the legacy ones: ``physical_energy`` is the - metric-weighted energy through the plane and ``analytic_flux`` the peak total - analytic Poynting flux, both scalars, because a coordinate energy and a - coordinate power are not additive on a basis that is not orthonormal - (``docs/guide/results/statistics.md``). ``energy``, ``peak_power``, - ``peak_power_total`` and ``peak_intensity`` are refused by name on this basis - rather than redefined. ``omega0``, - ``fwhm_t`` and ``fwhm_t_outer`` stay: they are functionals of the - same-frequency flux ``a.H W a`` and take the basis metric - (:data:`solprop.stats.METRIC_WEIGHTED_STATISTICS`), so they are exact on a - declared basis as well as on the identity one. - ``quadrature_error`` is not here either: a provider's node rule has - no embedded coarse pair to difference. - """ - return StatsSpec( - names=("omega0", "physical_energy", "analytic_flux", "fwhm_t", "fwhm_t_outer") - ) - - -def _in_channel( - built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], - guide: ModalGuide, - channel: int | str | None, -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Place a one-channel pulse in a named or indexed channel of a guide. - - **The amplitude is placed unchanged.** A provider normalises every channel to - unit longitudinal Poynting flux, so a channel amplitude is already a true-power - amplitude and the launched energy is the pulse's; there is no ``sqrt(Re n_eff)`` - boundary on this route and nothing here crosses one. - - - ``channel``: row index, display label, or ``None`` for the first channel. - """ - labels = [label.display_label for label in guide.mode_collection.channel_labels] - if built.shape[0] != 1: - msg = ( - f"a general modal launch places one pulse in one channel and this " - f"input carries {built.shape[0]}; build the state yourself and pass it " - "to Simulation.with_input to launch into several at once" - ) - raise DriverError(msg) - if channel is None: - index = 0 - elif isinstance(channel, str): - if channel not in labels: - msg = f"channel={channel!r} is not one of this guide's: {labels}" - raise DriverError(msg) - index = labels.index(channel) - else: - index = int(channel) - if not 0 <= index < len(labels): + raise DriverError(msg) + if gas is None: msg = ( - f"channel={channel!r} is outside this guide's {len(labels)} " - f"channel(s): {labels}" + "from_waveguide needs a gas fill for its supplied modes, or a " + "provider= whose material regions own their own laws and responses " + "(the general modal route)" ) raise DriverError(msg) - state = np.zeros((len(labels), *built.shape[1:]), dtype=np.complex128) - state[index] = built[0] - logger.debug("general modal launch into channel %d (%s)", index, labels[index]) - return state - - -def _general_pulse( - incident: SpatialPulse | None, - channel: int | str | None, - shape: str, - *, - wavelength: float, - fwhm: float | None, - width: float | None, - energy: float | None, - peak_power: float | None, - phases: Sequence[float] | np.ndarray, - pulses: Sequence[Pulse] | None, -) -> GaussianPulse | SechPulse | PulseSum | SpatialPulse: - """Return a general modal run's input: a shaped pulse, or an incident beam. - - - ``incident``: the incident beam, which carries its own temporal pulse; ``None`` - builds the pulse from the keywords. - - ``shape``, ``wavelength``, ``fwhm``, ``width``, ``energy``, ``peak_power``, - ``phases``, ``pulses``: the pulse keywords, as :func:`_shaped_pulse` takes them. - """ - if incident is None: - return _shaped_pulse( - shape, + return _from_hollow( + cls, + radius=None, + modes=modes, + mode_average=mode_average, + waveguide=True, + length=length, + gas=gas, + pressure=pressure, + pressure_floor=pressure_floor, + vacuum_end=vacuum_end, wavelength=wavelength, + wavelength_limits=wavelength_limits, + trange=trange, + grid=grid, + envelope=envelope, fwhm=fwhm, width=width, + shape=shape, energy=energy, peak_power=peak_power, phases=phases, pulses=pulses, + launch=launch, + susceptibility=susceptibility, + temperature=temperature, + kerr=kerr, + thg=thg, + plasma=plasma, + preionisation=preionisation, + plasma_integration=plasma_integration, + raman=raman, + n_r=n_r, + n_theta=n_theta, + radial_rule=radial_rule, + ionisation_field_range=ionisation_field_range, + absorber=absorber, + absorber_scheme=absorber_scheme, + boundary_check=boundary_check, + noise=noise, + seed=seed, + rtol=rtol, + atol=atol, + dz_max=dz_max, + dz_init=dz_init, + schedule=schedule, + integrator=integrator, + interpolate_planes=interpolate_planes, + step_planes=step_planes, + route="waveguide", + save_planes=save_planes, + n_save=n_save, + stats=stats, + polarisation_taper=polarisation_taper, + node_budget=node_budget, + backend=backend, + polarisation=polarisation, + taper_coupling=taper_coupling, + materials=MaterialSelection() if materials is None else materials, ) - if channel is not None: - msg = ( - "channel= and incident= are two ways to say what is launched and this " - "call gives both: name a channel and the pulse enters it unchanged, or " - "declare an incident beam and the channels capture what they overlap" - ) - raise DriverError(msg) - given = [ - name - for name, value in ( - ("fwhm", fwhm), - ("width", width), - ("energy", energy), - ("peak_power", peak_power), - ("pulses", pulses), - ) - if value is not None - ] - if len(phases): - given.append("phases") - if shape != "gauss": - given.append("shape") - if given: - msg = ( - f"incident= carries its own temporal pulse, and this call also sets " - f"{', '.join(given)}: give the beam's pulse its duration, energy and " - "phase, and leave the pulse keywords unset" - ) - raise DriverError(msg) - if isinstance(incident, PlanarPulse): - msg = ( - "a PlanarPulse launches into a planar guide's slab modes; the incident " - "beam of a general modal guide is a SpatialPulse" - ) - raise DriverError(msg) - if incident.propz != 0: - msg = ( - f"incident= is launched {incident.propz!r} m from its waist; carrying a " - "beam to the guide's entrance is a transport step this launch does not " - "model, so the beam is launched at its waist (propz=0)" - ) - raise DriverError(msg) - return incident - - -def _core_index( - regions: Sequence[MaterialRegion], grid: TimeGrid -) -> Float[np.ndarray, " n_omega"]: - """Return the real phase index of a guide's core at each of a grid's frequencies. - - The incident medium of a beam launched into the guide: the innermost material - region, whose law does not move with z, so it is the entrance's. - - - Returns: ``Re sqrt(eps)`` on the active positive band and zero elsewhere, shape - ``(n_omega,)``. - """ - core = regions[0] - if not isinstance(core.inner_radius, float | int) or core.inner_radius != 0: - msg = ( - f"an incident beam enters a guide through its core, the region on the " - f"axis, and this guide's first region {core.name!r} does not start there" - ) - raise DriverError(msg) - live = grid.active_mask & (grid.omega > 0) - index = np.zeros(grid.n_omega) - index[live] = np.real(np.sqrt(core.material.permittivity(grid.omega[live]))) - return index - - -_BEAM_EXTENT = 60.0 -"""Field exponent at the edge of an incident beam's radial plane. - -The plane extends to where a super-Gaussian of order N has fallen to -``exp(-_BEAM_EXTENT)`` in field, ``r = waist * _BEAM_EXTENT**(1/(2N))``, times the -margin :data:`_BEAM_MARGIN`; its intensity there is below 1e-52 of the peak. -""" - -_BEAM_MARGIN = 1.5 -"""Factor on the incident beam's plane radius beyond its decay radius.""" - -_BEAM_NODES = 256 -"""Radial nodes of an incident beam's plane. - -With the plane radius above, the largest transverse wavenumber is about -``pi * 256 / (1.5 * 60**(1/2N) * waist)``, 69/w for a Gaussian, where its spectrum -is below exp(-1000): the beam is resolved to rounding. -""" - - -def _beam_grid(pulse: SpatialPulse) -> RadialGrid: - """Return the radial plane an incident beam is sampled on before coupling.""" - radius = _BEAM_MARGIN * pulse.waist * _BEAM_EXTENT ** (1.0 / (2.0 * pulse.order)) - return RadialGrid(radius, _BEAM_NODES) - - -def _incident_launch( - simulation: "Simulation", - pulse: SpatialPulse, - model: CouplingModel, -) -> "Simulation": - """Couple an incident beam into a general modal simulation and install it. - - The beam becomes a radial :class:`~solprop.coupling.plane.FieldPlane` in the - guide's core index (:meth:`~solprop.coupling.plane.FieldPlane.from_pulse`, its - request met over its whole plane in the paraxial measure), and - :func:`~solprop.coupling.coupler.couple` recovers its coordinates in the guide's - channels under `model`. **Nothing is rescaled to the requested energy**: a - badly captured beam launches the little the channels take, and a beam they - cannot see is a valid zero launch. The launch is installed through - :meth:`Simulation.with_input`, which records its provenance as - ``configuration["coupling"]``; the coupling's report is recorded as - ``configuration["launch"]`` and the beam as ``configuration["pulse"]``. - """ - port = simulation.input_port() - index = ( - np.zeros(simulation.grid.n_omega) - if port.reference_index is None - else (np.asarray(port.reference_index)) - ) - try: - plane = FieldPlane.from_pulse( - pulse, - simulation.grid, - PlaneGeometry(port.geometry.z), - space=_beam_grid(pulse), - index=index, - ) - result = couple(plane, port, model=model) - except CouplingError as error: - raise DriverError(str(error)) from error - report = result.report - logger.info( - "incident beam coupled into %d channel(s) under %s: %.4g J incident, " - "%.4g J launched", - port.shape[0], - model.kind, - report.incident_energy, - report.launched_energy, - ) - launched = simulation.with_input( - result.launch, - noise=simulation.noise, - configuration={"pulse": pulse, "launch": report}, - ) - # The launch carries the constructor's realisation unchanged, so a draw made - # without a seed may still be drawn again from a recorded one. - launched._noise_redraw = simulation._noise_redraw - return launched - - -def _hollow_input( - pulse: Pulse, - launch: ModalLaunch | None, - grid: TimeGrid, - modes: Sequence[Mode], -) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], dict[str, object] | None]: - """Return a hollow guide's initial state, and its launch record if it has one. - - - ``launch``: the ``launch`` keyword; ``None`` puts the input in the first mode. - - Returns: ``configuration["modal_launch"]``, or ``None`` without a launch. - """ - if launch is None: - return _in_lowest_mode(pulse.build(grid), len(modes)), None - # A hollow guide's modes are orthonormal in true power, so the shares are energy - # fractions (SEP 41, S5). - return launch_pulse( - pulse, launch, grid, tuple(mode.channel_label for mode in modes) - ) -def _in_lowest_mode( - built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], n_modes: int -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return a one-mode input placed in the lowest mode of an `n_modes` set. - - A single pulse enters the fundamental and the higher modes start empty, filling - through the nonlinearity. - """ - if built.shape[0] > n_modes: - msg = ( - f"the input carries {built.shape[0]} modes and the set has {n_modes}; a " - "pulse cannot be placed in a mode that is not propagated" - ) - raise DriverError(msg, parameter=("pulses", "modes")) - if built.shape[0] == n_modes: - return built - state = np.zeros((n_modes, *built.shape[1:]), dtype=np.complex128) - state[: built.shape[0]] = built - return state - - -def _hollow_components(*, supplied: bool) -> tuple[str, ...] | None: - """Return the transverse components a hollow-guide run resolves. - - The capillary route's own set (a mode count) is unrotated HE_1m, which points along - x (``docs/dev/conventions.md`` section 5), and keeps ``("x",)`` bitwise. A supplied - set -- through either entry, with or without a coherent wall -- resolves the - components its modes need (``None``, inferred by - :func:`solprop.translate.problem.assemble`), so a TE_0m, TM_0m, EH or rotated set - keeps its E_y. A wall's coupled flow contracts the channel axis of the one-component - state and never reads the synthesis, so the backends admit it beside two synthesised - components. ``from_waveguide`` refuses a mode count before this, so only the - capillary entry reaches the first branch. - - - Returns: ``("x",)``, or ``None`` to infer the components from the modes. - """ - return None if supplied else DEFAULT_COMPONENTS - - -def _capillary_input_peak( - field0: Complex[np.ndarray, "n_modes n_pol n_omega"], - modes: ModeCollection, - grid: TimeGrid, - rule: TransverseQuadrature | None, -) -> tuple[float, float]: - """Return the input's peak field, which sets the ionisation table's range. - - The peak is the largest field amplitude of the analytic signal over the axis and the - run's own transverse nodes, which are where the plasma response is evaluated. On the - axis it is the quantity :data:`solprop.stats.CATALOGUE`'s ``peak_intensity`` is the - square of, and for an ``HE_1m`` launch the axis is the peak, so the range is - anchored to a number the run itself reports. A ``TE_0m`` or ``TM_0m`` field vanishes - on the axis (azimuthal order zero, but an azimuthal or radial vector field), so for - those the nodes carry the peak. The same peak is the reference field of an optmat - rate's constant energy per event (SEP 3, D8). - - - ``modes``: the mode set, whose profiles are evaluated on the axis and at the - nodes. - - ``rule``: the run's transverse quadrature; ``None`` for a mode-averaged run, whose - table the axis alone bounds: the area-mean field it samples lies below it. - - Returns: the peak field amplitude in V/m, and the circular share ``|S3| / S0`` of - the field's transverse components where it peaks, zero for a linear field. - """ - analytic = analytic_field(grid, field0) - limits = modes.dimlimits()[0] - peak_power = 0.0 - circular = 0.0 - for where in ( - (on_axis_rule(limits),) if rule is None else (on_axis_rule(limits), rule) +def _simulation_mixture(simulation: Simulation) -> Mixture: + """Require a material declaration the update can identify by component.""" + if simulation.medium is not None and isinstance( + simulation.medium.material, Mixture ): - matrices = mode_matrix(modes.modes, where, 0.0, components=("x", "y")) - rows = matrices.synthesis.reshape(matrices.n_nodes, matrices.n_pol, -1) - # One node at a time: the field at every node and time together would be - # n_nodes times the state's size, which a full angular rule makes large. - for node in rows: - columns = np.tensordot(node, analytic, axes=([1], [0])) - power = np.sum(np.abs(columns) ** 2, axis=tuple(range(columns.ndim - 1))) - index = int(np.argmax(power)) - if float(power[index]) > peak_power: - peak_power = float(power[index]) - # The Stokes parameter of the x and y components at the peak sample. - x, y = columns.reshape(columns.shape[0], -1, columns.shape[-1])[ - :, 0, index - ] - circular = abs(2.0 * float(np.imag(x * np.conj(y)))) / peak_power - peak = math.sqrt(peak_power) - logger.debug("input peak field over the axis and the nodes %.4e V/m", peak) - return peak, circular - - -@dataclasses.dataclass(frozen=True) -class _InputPeak: - """What the input's peak field is found from, evaluated only when it is needed. - - Attributes - ---------- - field0 : numpy.ndarray - The input state, shape ``(n_modes, n_pol, n_omega)``. - modes : solprop.modes.collection.ModeCollection - The mode set. - grid : solprop.grid.TimeGrid - The grid the state is stored on. - rule : solprop.modes.quadrature.TransverseQuadrature or None - The run's transverse quadrature; ``None`` for a mode-averaged run. - """ - - field0: Complex[np.ndarray, "n_modes n_pol n_omega"] - modes: ModeCollection - grid: TimeGrid - rule: TransverseQuadrature | None - - def evaluate(self) -> tuple[float, float]: - """Return the peak field in V/m and its circular share.""" - return _capillary_input_peak(self.field0, self.modes, self.grid, self.rule) - + return simulation.medium.material + materials = [getattr(mode, "mixture", None) for mode in simulation.modes] + if not materials or not isinstance(materials[0], Mixture): + raise DriverError("with_mixture needs a Mixture in the modes or spatial medium") + material = materials[0] + for index, other in enumerate(materials[1:], 1): + if serialise(other) != serialise(material): + raise DriverError(f"mode {index} carries a different mixture") + return material -def _ionisation_inputs( - responses: Sequence[object], - field_range: tuple[float, float] | None, - peak: _InputPeak | None, -) -> tuple[list[object], tuple[float, float] | None, float | None]: - """Return a capillary run's ionisation table range and its bound rates. - The input's peak field is found once, when the table's range is the default one - (:func:`default_field_range`) or an optmat rate waits for its reference field. +def _same_operator_function( + source: Simulation, + updated: Simulation, + old: Mixture, + new: Mixture, + expansion: ParameterExpansion | None, +) -> ParameterExpansion | None: + """Keep terms only when every dependency outside their axes is unchanged. - - ``field_range``: the caller's table range in V/m, or ``None`` for the default. - - ``peak``: where the input's peak is found from; ``None`` for a run without plasma. - - Returns: the responses (optmat rates bound to the peak), the table range, and the - reference field in V/m the rates were bound to, or ``None``. - """ - unbound = _unbound_rates(responses) - if peak is None or not (field_range is None or unbound): - return list(responses), field_range, None - found = peak.evaluate() - if field_range is None: - field_range = default_field_range(found[0]) - if not unbound: - return list(responses), field_range, None - return _bound_rates(responses, found, field_range), field_range, found[0] - - -def _peak_field_reports( - problem: PropagationProblem, - output: Output, - responses: Sequence[object], -) -> tuple[PeakFieldReport, ...]: - """Report the largest field each optmat-rate population saw, after a run. - - The populations' peak rates at the save planes are read back from the run's - output -- ``species_peak_ionisation_rate``, or ``peak_ionisation_rate`` for one - population -- and inverted through each rate - (:func:`solprop.nonlinear.optmat_rates.peak_field_report`). - - - Returns: one per population with an optmat rate; none when the run carries no such - rate or recorded no peak rate, which is logged. + - ``expansion``: previously sampled coefficients and axis names. + - Returns: the old coefficients when the function is identical, else None with a + log. """ - labels = problem.responses.labels - rates = [ - ( - one.species - or (labels[0] if len(labels) == 1 else str(getattr(one.gas, "name", ""))), - one.rate, - ) - for one in responses - if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - and isinstance(one.rate, OptmatRate) - ] - if not rates: - return () - if isinstance(output, HDF5Output) and output.closed: - with HDF5Output(output.path, readonly=True) as reader: - return _peak_field_reports(problem, reader, responses) - if "species_peak_ionisation_rate" in output.stat_names: - rows = np.asarray(output["stats/species_peak_ionisation_rate"]) - peaks = np.max(rows, axis=0) - elif "peak_ionisation_rate" in output.stat_names and len(labels) == 1: - rows = np.asarray(output["stats/peak_ionisation_rate"]) - peaks = np.array([np.max(rows)]) - else: - logger.info( - "the run recorded no peak ionisation rate, so the largest field its " - "optmat rates saw is not reported" - ) - return () - envelope = problem.responses.envelope_plasma - cycles = ( - {} - if envelope is None - else {channel.label: channel.cycle for channel in envelope.channels} - ) - reports = [] - for label, rate in rates: - report = peak_field_report( - rate, - float(peaks[labels.index(label)]), - label=label, - # An envelope's statistic is its own cycle average of its table, at - # linear polarisation the cosine moment alone. - average=None - if label not in cycles - else functools.partial(_linear_moment, cycles[label]), + if source.settings.prefactor_index != updated.settings.prefactor_index: + logger.info("Mixture update: expansion reuse refused; prefactor_index changed") + return None + if expansion is None: + return None + if len(old.components) > 1 and ( + (old.shared is None) != (new.shared is None) + or ( + old.shared is not None + and (old.fractions != new.fractions or old.temperature != new.temperature) ) - if report.reached_maximum: - _optmat.warn_once( - ("rate-reached-maximum", rate.material_records[0].digest), - f"{label}: the run reached the maximum field " - f"{report.maximum_field:.4g} V/m of optmat's {report.model}, above " - "which its rate is held at its value there; the largest field is at " - "least that", - MaterialRangeWarning, - ) + ): logger.info( - "%s: largest field %s V/m (maximum %.4g V/m); rate-weighted energy there " - "%s against the constant %.6g J", - label, - "none" if report.largest_field is None else f"{report.largest_field:.4g}", - report.maximum_field, - "none" - if report.departure is None - else f"departs by {report.departure:+.3e}", - report.constant_energy, - ) - reports.append(report) - return tuple(reports) - - -def _check_rate_inputs( - simulation: Simulation, initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray -) -> None: - """Check a run's input against each optmat rate it carries, once per run. - - The reference field and the table range stay those the run was built with; the - input is checked against each rate's maximum field and, when it is not linearly - polarised, the Keldysh parameter at its peak - (:meth:`solprop.nonlinear.optmat_rates.OptmatRate.check_input`). On a mode-set - route the peak and the polarisation are the field's over the axis and the nodes - (:func:`_capillary_input_peak`); on a free-space route the peak is the physical - intensity's maximum and the polarisation the pulse's Jones vector, which a - spectrum given as an array does not declare, so it is taken as linear there. - """ - rates = [ - one.rate - for one in simulation.responses - if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - and isinstance(one.rate, OptmatRate) - ] - if not rates: - return - if simulation.spatial_grid is not None: - peak, circular = _spatial_input_peak(simulation, initial) - elif simulation.general is None and simulation.settings.quadrature is not None: - peak, circular = _capillary_input_peak( - simulation._field0, - ModeCollection(list(simulation.modes)), - simulation.grid, - simulation.settings.quadrature, - ) - else: - return - for rate in rates: - rate.check_input(peak, circular=circular) - - -def _bind_spatial_rates( - simulation: Simulation, initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray -) -> None: - """Bind a free-space or planar run's optmat rates to its input, and check it. - - The spatial routes -- radial, Cartesian, line and planar -- bind an optmat rate's - constant energy per event to the input's peak field, as the capillary route does - (SEP 3, Q14); binding also checks the input against each rate. A run whose rates - are all bound is only checked. - """ - if _unbound_rates(simulation.responses): - simulation.responses = tuple( - _bound_rates( - simulation.responses, - _spatial_input_peak(simulation, initial), - simulation.settings.ionisation_field_range, - ) + "Mixture update: rebuild expansion; fill fractions, EOS temperature " + "or parameterisation changed" ) + return None + positions = np.asarray(expansion.z_span) + if source.medium is not None and updated.medium is not None: + paths = [(source.medium.parameter_paths, updated.medium.parameter_paths)] else: - _check_rate_inputs(simulation, initial) - - -def _spatial_input_peak( - simulation: Simulation, initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray -) -> tuple[float, float]: - """Return a free-space or planar run's peak input field and its circular share. - - - Returns: the peak physical field amplitude in V/m, and the circular share - ``|S3|/S0`` of the pulse's Jones vector; a spectrum given as an array declares - none and is taken as linear. - """ - space = cast("RadialGrid | FreeGrid | PlanarGrid", simulation.spatial_grid) - # Ionisation is driven by the physical field, never by a signed axial flux. - peak = peak_field(simulation.grid, space, simulation._field0) - jones = ( - np.asarray(initial.jones, dtype=np.complex128) - if isinstance(initial, SpatialPulse | SpatialPulseSum) - else np.ones(1, dtype=np.complex128) - ) - circular = ( - abs(2.0 * float(np.imag(jones[0] * np.conj(jones[1])))) - / float(np.sum(np.abs(jones) ** 2)) - if jones.size == 2 - else 0.0 - ) - return peak, circular - - -def _linear_moment(cycle: CycleIntegralData, amplitude: float) -> float: - """Return an envelope run's cycle-averaged rate at a linear amplitude. - - - ``amplitude``: envelope amplitude in V/m. - - Returns: the rate in s^-1 its statistic records there. - """ - return float(cycle_moments(amplitude, 0.0, cycle)[0]) - - -def _unbound_rates(responses: Sequence[object]) -> bool: - """Return whether a plasma response carries an optmat rate without a reference. - - - Returns: True when a rate's constant energy per event still waits for the input's - peak. - """ - return any( - isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - and isinstance(one.rate, OptmatRate) - and one.rate.reference_field is None - for one in responses - ) - + paths = [ + (before.parameter_paths, after.parameter_paths) + for before, after in zip(source.modes, updated.modes, strict=True) + if isinstance(before, ParameterisedMode) + and isinstance(after, ParameterisedMode) + ] + for before_paths, after_paths in paths: + for name, path in before_paths.items(): + if name in expansion.parameters: + continue + other = after_paths.get(name) + if ( + other is None + or not other.is_constant + or not np.array_equal(path(positions), other(positions)) + ): + logger.info( + "Mixture update: rebuild expansion; fixed parameter %s changed", + name, + ) + return None + return expansion -def _bound_rates( - responses: Sequence[object], - peak: tuple[float, float], - field_range: tuple[float, float] | None, -) -> list[object]: - """Bind a run's optmat rates to the input's peak field, and check the input. - - ``peak``: the input's peak field in V/m and its circular share there. - - ``field_range``: the field amplitudes in V/m the run tabulates its rates over. - - Warns once per run when the peak exceeds a rate's maximum field, or the input is - not linearly polarised at a Keldysh parameter above one - (:meth:`solprop.nonlinear.optmat_rates.OptmatRate.check_input`). - """ - field, circular = peak - bound = list(bind_reference_field(responses, field, field_range)) - for one in bound: - if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) and isinstance( - one.rate, OptmatRate +def _mixture_reuse( + source: Simulation, + updated: Simulation, + old: Mixture, + new: Mixture, + problem: PropagationProblem, +) -> TranslationReuse: + """Collect eligible immutable translation data after a material-path update.""" + before = { + response.species or old.labels[0]: response + for response in source.responses + if isinstance(response, RamanResponse) + } + after = { + response.species or new.labels[0]: response + for response in updated.responses + if isinstance(response, RamanResponse) + } + kernels = {} + for leaf in problem.responses.raman_terms: + if _undamped_model_key(before[leaf.label]) == _undamped_model_key( + after[leaf.label] ): - one.rate.check_input(field, circular=circular) - return bound - - -def _rebind_mode(mode: Mode, mixture: Mixture) -> Mode: - """Bind a gas fill through the mode's explicit material interface. - - The mode's own gas, pressure and temperature (a temperature path included) are - replaced by the mixture's; only its geometry is kept. - """ - rebind: Callable[[Mixture], Mode] | None = getattr(mode, "with_mixture", None) - if not callable(rebind): - # The hint belongs to the mode that has a route: a step-index fibre is a - # mode-averaged GNLSE run. Anything else gets the plain refusal. - message = f"{type(mode).__name__} cannot replace its material with a Mixture" - if isinstance(mode, StepIndexMode): - message += "; use prop_gnlse(fibre=...) instead" - raise DriverError(message) - return rebind(mixture) - - -def _hollow_stats( - *, - with_plasma: bool, - mixture: Mixture, - profiles: Mapping[str, ZProfile], - rule: TransverseQuadrature | None, -) -> StatsSpec: - """Keep capillary statistics where the supplied geometry defines them. - - - ``rule``: the run's fixed transverse rule, whose embedded coarse rule the - quadrature alarm needs; ``None`` for a mode-averaged run. - - Returns: statistics with unsupported geometry paths omitted. - """ - base = _capillary_stats( - with_plasma=with_plasma, mixture=mixture, averaged=rule is None - ) - dropped = {name for name in ("core_radius",) if name not in profiles} - if rule is not None and not rule.kronrod: - # Without an embedded coarse rule the estimate is NaN at every plane, so - # the statistic is dropped rather than recorded empty. A Cartesian - # product rule has no Kronrod extension at all (quadrature_rule stores - # kronrod only for a polar rule), so the alarm is off for every - # rectangular run, and the log below says so rather than leaving it silent. - dropped.add("quadrature_error") - logger.info( - "the %s rule has no embedded coarse rule, so the quadrature alarm is " - "off and quadrature_error is not recorded", - rule.kind, - ) - return dataclasses.replace( - base, names=tuple(name for name in base.names if name not in dropped) + kernels[leaf.label] = leaf.undamped + else: + logger.info("Mixture update: rebuild %s undamped Raman model", leaf.label) + return TranslationReuse( + problem.transform, + problem.initial, + _same_operator_function( + source, updated, old, new, problem.linear.phi_expansion + ), + _same_operator_function( + source, updated, old, new, problem.responses.prefactor_expansion + ), + kernels, + transverse_of(problem.route), ) -def _refuse_dispersive_policy(materials: MaterialSelection | None, route: str) -> None: - """Refuse a dispersive electronic policy on a route whose response is constant.""" - if materials is None or materials.electronic not in DISPERSIVE_POLICIES: - return - msg = ( - f"the electronic policy {materials.electronic!r} evaluates optmat's " - f"dispersive hyperpolarizability model, and {route} is a constant coefficient " - "this route keeps: select 'carrier-kerr' (or 'recommended') here" - ) - raise DriverError(msg, parameter="materials.electronic", rule="gnlse-carrier-kerr") +def _undamped_model_key(response: RamanResponse) -> object: + """Describe only the inputs to the undamped Raman samples.""" + models = [] + for term in response.tensor_terms: + model = term.model + record = serialise(model) + if isinstance(model, (DampedOscillator, RotationalLines)): + assert isinstance(record, dict) + record.pop("dephasing", None) + models.append((term.contraction, record)) + return models -def _check_electronic_policy( - materials: MaterialSelection | None, responses: Sequence[object] -) -> None: - """Refuse a constant Kerr response under a dispersive electronic policy. +def _warn_adiabatic_taper(modes: Sequence[Mode]) -> None: + """Warn, once per translation, that a moving multimode set omits its connection. - A caller's own responses are taken as they are, but a constant - :class:`~solprop.nonlinear.responses.KerrResponse` beside a selection that names a - dispersive policy would record one law and propagate another. + A mode set whose transverse shape changes along z -- a capillary or a fibre on a + taper -- couples its channels through the moving basis, ````, and + the diagonal route propagates each channel adiabatically with that term absent. A + single channel needs no connection, and a guide whose shape does not move -- a + constant bore, or a pressure gradient in a capillary, whose modes depend on the + radius alone -- has none. The coherent wall route carries the connection and never + reaches here, and neither does a run that declined it (``taper_coupling=False``); a + capillary's contiguous ``HE_1m`` set carries it by default + (:func:`solprop.modes.capillary.taper_coupling_block`), so what warns is a moving + set the coupling is not derived for. """ - if materials is None or materials.electronic not in DISPERSIVE_POLICIES: + channels = tuple(modes) + if len(channels) < 2: return - if any(isinstance(response, KerrResponse) for response in responses): - msg = ( - f"the selection's electronic policy is {materials.electronic!r} and the " - "run declares a constant KerrResponse; build the electronic response as " - "a DispersiveKerrResponse (solprop.nonlinear.mixtures." - "electronic_response), or select 'carrier-kerr'" - ) - raise DriverError(msg) - - -def _check_selection_carrier( - materials: MaterialSelection | None, - reference: float, - wavelength: float | None = None, -) -> None: - """Refuse a carrier that is not the run's: one carrier sets the Kerr coefficient. - - The electronic coefficient, the Raman response and the rate are all evaluated at - the carrier. - - - ``reference``: the grid's reference wavelength in m, the run's carrier. - - ``wavelength``: the driver's `wavelength` in m, at which the responses are built. - """ - if materials is None: + moving = [ + index + for index, mode in enumerate(channels) + if not getattr(mode, "radius_is_constant", mode.z_constant) + ] + if not moving or taper_coupling_block(channels) is not None: return - # Without the driver's wavelength the reference is a grid given whole. - carrier = ( - "materials.carrier_wavelength", - "grid" if wavelength is None else "wavelength", - ) - for name, value, parameter in ( - ("the selection's carrier_wavelength", materials.carrier_wavelength, carrier), - ("wavelength", wavelength, ("wavelength", "grid")), - ): - if value is not None and not carriers_agree(float(value), reference): - msg = ( - f"{name} is {value:g} m and the grid's reference wavelength " - f"{reference:g} m: under a material selection one carrier sets the " - "Kerr coefficient of every optmat species (SEP 3, section 4.3), and " - "it is the grid's. Build the grid at that wavelength, or leave the " - "selection's carrier_wavelength unset" - ) - raise DriverError(msg, parameter=parameter) - - -def _default_cladding(cladding: GlassIndex | complex | None) -> GlassIndex | complex: - """Return the cladding a numbered capillary set takes. - - - ``cladding``: the cladding given; ``None`` is the package default. - """ - return fused_silica() if cladding is None else cladding - - -def _selected_medium( - medium: SpatialMedium, materials: MaterialSelection -) -> SpatialMedium: - """Resolve a bulk medium's built-in gas under a material selection. - - A medium that names a built-in gas meant the built-in gas: it becomes that gas's - one-population mixture with the selection's models, at the same pressure path and - temperature, so that it is recorded; a supplied susceptibility is kept beside - optmat's equation of state, as for a capillary fill. Any other medium is taken as - it is. - """ - if not isinstance(medium.material, Gas): - return medium - gas = medium.material - selected = species(gas, selection=materials) - if medium.susceptibility != default_species(gas).susceptibility: - selected = dataclasses.replace(selected, susceptibility=medium.susceptibility) - # A constructed medium holds its resolved temperature, a number. - fill = Mixture.fill( - {selected: 1.0}, medium.pressure, temperature=cast("float", medium.temperature) - ) - return SpatialMedium(fill) - - -def _selected_guide(guide: ModalGuide, materials: MaterialSelection) -> ModalGuide: - """Resolve a guide's default delayed silica response under a material selection. - - A region's :class:`~solprop.nonlinear.raman.SilicaTensorResponse` with the - default parameters, optmat's recommended tensor bundle, meant the built-in silica - tensor response; under the selection it becomes the tensor bundle its ``raman`` - names (:meth:`~solprop.physdata.glasses.SilicaBundle.resolve` with - ``tensor=True``). A response declared with any other parameters, and a provider - of another kind, are taken as they are. - - - Returns: the guide to propagate on: the one given when nothing resolves, otherwise - the same provider, channels and sampling with the regions' delayed responses - resolved. - """ - default = SilicaTensorResponse() - if not any(region.raman == default for region in guide.regions): - return guide - provider = guide.field_provider - if not isinstance(provider, StepIndexProvider | GradedIndexProvider): - return guide - resolved = SilicaTensorResponse( - parameters=SilicaBundle.resolve(materials.raman, tensor=True) - ) - - def selected(region: MaterialRegion) -> MaterialRegion: - """Return a region with its default delayed response resolved.""" - return dataclasses.replace( - region, - responses=tuple( - resolved if one == default else one for one in region.responses - ), - ) - - rebuilt = ( - dataclasses.replace( - provider, regions=tuple(selected(one) for one in provider.regions) - ) - if isinstance(provider, StepIndexProvider) - else dataclasses.replace( - provider, - core_responses=selected(provider.regions[0]).responses, - ) - ) - return dataclasses.replace( - guide, field_provider=rebuilt, regions=tuple(rebuilt.regions) - ) - - -def _selected_planar(space: PlanarGrid, materials: MaterialSelection) -> PlanarGrid: - """Resolve a planar grid's built-in materials under a material selection. - - The slab modes' fill of a built-in gas with its default susceptibility meant the - built-in gas: it takes the selection's single-species susceptibility - (:func:`solprop.physdata.species.species`), and keeps its pressure, temperature - and optmat density path, so no mixture -- which the hybrid transform does not - admit -- is needed (SEP 3, Q5). A slab declared without a cladding takes optmat's - recommended silica. A supplied susceptibility and a declared cladding are the - caller's. - - - Returns: the grid given when nothing resolves; otherwise the same grid rebuilt on - the resolved slab modes. - """ - modes = cast("tuple[SlabMode, ...]", tuple(space.modes)) - first = modes[0] - changes: dict[str, object] = {} - if first.mixture is None and first.gas is not None: - gas = as_gas(first.gas) - if first.susceptibility == default_species(gas).susceptibility: - # The modes share one fill (`PlanarGrid` refuses any other), so one - # resolved susceptibility serves them all and the medium is checked - # against them by identity. - resolved = species(gas, selection=materials).susceptibility - if resolved != first.susceptibility: - changes["susceptibility"] = resolved - cladding = fused_silica() - rebuilt = tuple( - dataclasses.replace( - mode, - **changes, - **({"cladding": cladding} if mode.cladding is None else {}), - ) - if changes or mode.cladding is None - else mode - for mode in modes - ) - if all(one is two for one, two in zip(rebuilt, modes, strict=True)): - return space - with warnings.catch_warnings(): - # The caller's own grid already said so if its n_x is below the bound. - warnings.filterwarnings( - "ignore", message=r"PlanarGrid n_x = ", category=UserWarning - ) - return dataclasses.replace(space, modes=rebuilt) - - -def _planar_medium(space: PlanarGrid) -> SpatialMedium: - """Return the fill of a planar grid's modes as a constant bulk medium.""" - mode = cast("SlabMode", space.modes[0]) - if mode.mixture is not None: - msg = ( - "a planar run takes a single-gas fill: a mixture on the hybrid planar " - "transform is not admitted yet; fill the slab with gas= and pressure=" - ) - raise DriverError(msg) - if not mode.density.is_constant: - msg = ( - "the hybrid planar transform requires constant media: a gas path along z " - "(a varying pressure or temperature) is refused, as for Cartesian free " - "space, whose seam evaluates a constant operator exactly and has no " - "varying-medium expansion" - ) - raise DriverError(msg) - if mode.gas is None: - return SpatialMedium() - return SpatialMedium( - mode.gas, - cast("float | ZProfile", mode.pressure), - temperature=cast("float", mode.temperature), - susceptibility=mode.susceptibility, + warnings.warn( + f"{len(channels)} channels propagate on a guide whose modes change shape " + f"along z (channel {moving[0]} first), and this route omits the connection " + " between them: the inter-mode conversion a taper drives is " + "absent and each channel follows its local mode adiabatically. A " + "capillary's contiguous HE_11 to HE_1N set carries it by default, with or " + "without wall=CoherentWall(); this set is not that block " + "(docs/guide/waveguides/taper-coupling.md)", + AdiabaticTaperWarning, + stacklevel=3, ) - - -def _boundary( - absorber: Absorber | Literal[False] | None, - scheme: AbsorberScheme | None, - grid: TimeGrid, - *, - default: Callable[[TimeGrid], Absorber] | None = None, -) -> tuple[Absorber | None, AbsorberScheme | None]: - """Resolve a builder's absorber keywords (SEP 21 section 5). - - - ``absorber``: the collar given; ``False`` switches both soft collars off and keeps - the hard band mask; ``None`` takes `default`. - - ``scheme``: the temporal scheme given; ``None`` is the default scheme. - - ``default``: the route's collar when none is given; ``None`` is - :func:`default_collar`. - - Returns: the absorber (``None`` for none) and the scheme, ``None`` where the - settings are to take the route's default. - """ - if absorber is False: - if scheme is not None: - msg = ( - "absorber=False switches the temporal collar off, so there is nothing " - f"for absorber_scheme={scheme!r} to apply; give one or the other" - ) - raise DriverError( - msg, - parameter=("absorber", "absorber_scheme"), - rule="absorber-scheme-needs-absorber", - ) - return no_absorber(grid), None - if absorber is None: - absorber = default_collar(grid) if default is None else default(grid) - return absorber, scheme - - -def default_collar(grid: TimeGrid) -> Absorber: - """Return a route's default absorbing boundaries on a grid (SEP 21). - - :func:`solprop.boundaries.absorber` at its defaults, spectral-only when the grid - leaves too little room outside the request for a temporal collar to taper - (``collar_fraction=0`` and no rounding slack). - - Parameters - ---------- - grid : TimeGrid - The run's grid. - - Returns - ------- - Absorber - The default collar. - - Examples - -------- - >>> from solprop.driver.simulation import default_collar - >>> from solprop.grid import FieldGrid - >>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15) - >>> default_collar(grid).temporal - True - """ - try: - return collar(grid) - except GridError: - # No room for a temporal taper outside the request: the spectral collar alone. - return collar(grid, temporal=False) - - -def _spatial_default( - settings: SimulationSettings, grid: TimeGrid -) -> SimulationSettings: - """Give a spatial run whose settings declare no absorber the default collar. - - The temporal and spectral collars become the routes' default (SEP 21, D6); the - transverse boundary is the settings' ``spatial_absorber`` (SEP 40). - """ - if settings.absorber is not None: - return settings - return dataclasses.replace(settings, absorber=default_collar(grid)) - - -def _transverse_specification( - setting: SpatialAbsorber | Literal[False] | None, - spatial_grid: object, -) -> SpatialAbsorber | None: - """Return the transverse boundary a run's translation resolves (SEP 40). - - - ``spatial_grid``: the run's transverse grid, ``None`` for a modal run. - - Returns: the specification; ``None`` for no ramp and no collar. - """ - if spatial_grid is None: - if isinstance(setting, SpatialAbsorber): - msg = ( - "spatial_absorber sets a free-space run's transverse boundary; a " - "modal route has no transverse window" - ) - raise DriverError(msg) - return None - if setting is False: - return None - return SpatialAbsorber(real_space=False) if setting is None else setting diff --git a/src/solprop/driver/summary.py b/src/solprop/driver/summary.py index 63778876..a190d9f2 100644 --- a/src/solprop/driver/summary.py +++ b/src/solprop/driver/summary.py @@ -151,7 +151,7 @@ def run_summary( Examples -------- - >>> from solprop.driver.simulation import metadata_for + >>> from solprop.driver.metadata import metadata_for >>> from solprop.driver.summary import run_summary >>> from solprop.grid import EnvelopeGrid >>> from solprop.nonlinear.gnlse import problem_from diff --git a/src/solprop/driver/tdse_run.py b/src/solprop/driver/tdse_run.py new file mode 100644 index 00000000..5d811786 --- /dev/null +++ b/src/solprop/driver/tdse_run.py @@ -0,0 +1,602 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The run glue of a TDSE response (SEP 20): its entry, archive and atomic state. + +The ``tdse=`` capillary keywords resolve here, and a run with a TDSE response opens its +archive, prepares and restores its atomic state, and passes the consistency gate before +its march. +""" + +import dataclasses +import json +import logging +import uuid +import warnings +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import numpy as np + +import solprop.driver.tdse as tdse_runtime +from solprop.backends.protocol import Backend +from solprop.backends.session import Start +from solprop.boundaries import absorber as collar +from solprop.boundaries import temporal_collar_rate +from solprop.driver import tdse_consistency +from solprop.driver.run import open_run_record +from solprop.driver.settings import SimulationSettings, _scheme +from solprop.exceptions import DriverError, TDSEArchiveError, TDSECostWarning +from solprop.grid import TimeGrid +from solprop.modes.material import JsonValue +from solprop.modes.quadrature import TransverseQuadrature +from solprop.nonlinear.tdse import TDSEAtomicWindow, TDSEExecution, TDSEGasResponse +from solprop.output.hdf5 import HDF5Output, ResumeState +from solprop.output.schema import Output, RunMetadata +from solprop.output.tdse_archive import TDSEArchive, TDSEArchiveWriter +from solprop.stats import StatsSpec +from solprop.stepper.multistep import Multistep +from solprop.translate.problem import PropagationProblem +from solprop.translate.routes import tdse_descriptor +from solprop.translate.tdse import AtomicCallLedger, TDSEBinding, refine_response_grid +from solprop.translate.tdse_source import TDSE_UNAVAILABLE_STATISTICS + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [] + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _AtomicContext: + """Hold what preparing a TDSE run's atomic resources needs from the run. + + Attributes + ---------- + handler : Output + The optical output, not yet opened. + state : ResumeState or None + What a resumed file holds. + metadata : RunMetadata + The run's record. + """ + + handler: Output + state: ResumeState | None + metadata: RunMetadata + + +def _prepare_atomic( + simulation: "Simulation", + problem: PropagationProblem, + context: _AtomicContext, + tdse_archive: TDSEArchive | None, +) -> tuple[TDSEBinding, TDSEArchiveWriter | None]: + """Open a TDSE run's archive and prepare its binding. + + - Returns: the binding and the open archive session, or None without a request. + A failure closes the archive before it propagates. + """ + descriptor = tdse_descriptor(problem.route) + assert descriptor is not None + _warn_tdse_cost(simulation.settings.quadrature) + execution = simulation.settings.tdse_execution + if execution is None: + raise DriverError("TDSE requires explicit tdse_execution settings") + # Opened and validated before any atomic work (SEP 20, 5.8). + archive = _open_tdse_archive( + tdse_archive, + problem, + context.handler, + context.state, + context.metadata, + execution, + ) + try: + # A haCC binding builds its propagation plans only after the + # consistency check, so the check's one-row plan never adds to their + # peak memory (_consistency_gate). + binding = tdse_runtime.prepare_binding( + descriptor, + execution, + problem.transform.n_nodes, + defer_plans=descriptor.model == "hacc", + archive=archive, + ) + except BaseException as error: + _close_tdse_archive(archive, error) + raise + return binding, archive + + +def _restore_atomic(binding: TDSEBinding | None, start: Start) -> None: + """Restore a resumed run's cumulative atomic accounting into its binding. + + Before anything can charge the binding: a haCC run's consistency gate makes a + diagnostic provider call when its local cache has no entry, and a budget that + has begun work refuses to be restored. The start keeps only the logical + ledger, which the session restores when it loads the start. + + - ``binding``: the run's open atomic binding; None for a run without one. + """ + ledgers = start.ledgers + if binding is None or not ledgers or not ledgers.get("atomic"): + return + atomic = cast("Mapping[str, object]", ledgers["atomic"]) + binding.restore_ledger(AtomicCallLedger.from_record(atomic)) + start.ledgers = {"logical": ledgers.get("logical", {})} + + +def _consistency_gate( # noqa: PLR0917 - called positionally + simulation: "Simulation", + binding: TDSEBinding | None, + problem: PropagationProblem, + handler: Output, + metadata: RunMetadata, + backend: Backend, +) -> None: + """Check a haCC run's weak-field linear response before propagating. + + The cached or computed result is attached to the binding's provenance, under + ``tdse.consistency``. The binding was prepared with deferred plans, so the + check's temporary one-row plan is closed before the propagation plans are + built here. A refused or invalid result is written to the requested + output, as the opened run record without any plane or checkpoint, before + :class:`~solprop.exceptions.TDSEConsistencyError` is raised (SEP 20, 5.4). + + - ``binding``: the run's open atomic binding; None for a run without one. + """ + descriptor = tdse_descriptor(problem.route) + if binding is None or descriptor is None or descriptor.model != "hacc": + return + declared = next( + one for one in simulation.responses if isinstance(one, TDSEGasResponse) + ) + if not isinstance(binding, tdse_consistency.GateBinding): + raise DriverError("a haCC run needs a binding with a diagnostic row") + build = cast("dict[str, object]", binding.metadata().get("build", {})) + native = cast("dict[str, object]", build.get("native", {})) + result = tdse_consistency.gate_run( + binding, + declared, + reference_wavelength=simulation.grid.reference_wavelength, + provider_revision=tdse_runtime.pinned_revision(), + computed_on={ + "execution_space": native.get("execution_space"), + "build": build, + "threads": native.get("requested_threads"), + }, + ) + binding.record_consistency(result.to_record()) + if result.outcome in ("refused", "invalid"): + try: + open_run_record(handler, metadata, backend, binding) + except Exception: + logger.exception("could not record the refused consistency check") + tdse_consistency.apply_outcome(result) + # The diagnostic's temporary plan is closed: only now the propagation plans. + if not binding.plans_built: + binding.build_plans() + + +def _check_tdse_archive( + archive: object, problem: PropagationProblem, handler: Output +) -> None: + """Refuse an archive request this run cannot honour, before any work. + + - ``archive``: the request, checked here to be a ``TDSEArchive`` or None. + """ + if archive is None: + return + if not isinstance(archive, TDSEArchive): + raise DriverError("tdse_archive must be a TDSEArchive or None") + if tdse_descriptor(problem.route) is None: + raise DriverError( + "tdse_archive archives a TDSE response's provider evaluations; this run " + "has no TDSE response" + ) + if isinstance(handler, HDF5Output) and _same_file(archive.path, handler.path): + raise DriverError( + f"the TDSE archive {archive.path} must differ from the optical HDF5 file" + ) + + +def _same_file(first: Path, second: Path) -> bool: + """Return whether two paths name the same file, existing or not. + + - Returns: True when they resolve to one location. + """ + if first.exists() and second.exists(): + return first.samefile(second) + return first.resolve() == second.resolve() + + +def _recorded_archive_uuid(state: ResumeState) -> str | None: + """Return the archive run UUID a resumed optical file records, if any. + + The latest execution's record wins, then the closing records of the earlier + executions, latest first: an archive first requested by a resume is recorded + in that execution's outcome, not in the file's original record. A TDSE resume + always has an outcome (:func:`_validate_resume`). + + - Returns: the UUID, or None when no execution of the run archived. + """ + outcome = state.outcome + # `_validate_resume` has refused a TDSE file without its ledgers and outcome. + assert outcome is not None + records: list[object] = [outcome.execution] + records.extend( + previous.get("execution") for previous in reversed(outcome.previous_executions) + ) + for record in records: + tdse = cast("Mapping[str, object]", record).get("tdse") + archive = tdse.get("archive") if isinstance(tdse, Mapping) else None + value = ( + cast("Mapping[str, object]", archive).get("run_uuid") + if isinstance(archive, Mapping) + else None + ) + if isinstance(value, str): + return value + return None + + +def _open_tdse_archive( # noqa: PLR0917 - called positionally + archive: TDSEArchive | None, + problem: PropagationProblem, + handler: Output, + state: ResumeState | None, + metadata: RunMetadata, + execution: TDSEExecution, +) -> TDSEArchiveWriter | None: + """Open the run's archive session, continuing its own run's archive on a resume. + + - ``state``: what a resumed file holds, or None for a fresh run. + - Returns: the open session, or None without a request. + """ + descriptor = tdse_descriptor(problem.route) + if archive is None or descriptor is None: + return None + recorded = None if state is None else _recorded_archive_uuid(state) + weights = problem.transform.node_weights + context: dict[str, object] = { + "descriptor": json.loads(descriptor.configuration_json), + "descriptor_identity": descriptor.identity, + "model": descriptor.model, + "n_nodes": problem.transform.n_nodes, + "node_weights_m2": None if weights is None else weights.tolist(), + "quadrature": metadata.quadrature, + "execution": dataclasses.asdict(execution), + "provider_pin": tdse_runtime.pinned_revision(), + # The file's own record on a resume, which the session holds and a snapshot + # compares with. + "optical_configuration_hash": ( + metadata if state is None else state.metadata + ).configuration_hash(), + "versions": dict(metadata.versions), + } + return TDSEArchiveWriter.open( + archive, + run_uuid=str(uuid.uuid4()) if recorded is None else recorded, + kind="run" if state is None else "resume", + optical=handler.path if isinstance(handler, HDF5Output) else None, + context=context, + resume=recorded is not None, + ) + + +def _close_run( + backend: Backend | None, + binding: TDSEBinding | None, + archive: TDSEArchiveWriter | None, + handler: Output, + failure: BaseException | None, +) -> None: + """Close a run's resources in order, each even when an earlier close fails. + + The backend before the atomic binding, the binding before the archive that + records it, and the optical output last. + + - ``failure``: the error the run is raising, if any. + """ + closing = failure + for resource in (backend, binding, archive, handler): + if resource is None: + continue + try: + resource.close() + except BaseException as error: + if closing is None: + closing = error + else: + closing.add_note(f"a run resource could not be closed: {error}") + if failure is None and closing is not None: + raise closing + + +def _close_tdse_archive( + archive: TDSEArchiveWriter | None, failure: BaseException | None +) -> None: + """Close the archive session, never masking the run's own error. + + - ``failure``: the error the run is raising, if any. + """ + if archive is None: + return + try: + archive.close() + except TDSEArchiveError as error: + if failure is None: + raise + failure.add_note(f"the TDSE archive could not be closed: {error}") + logger.exception("could not close the TDSE archive") + + +def _resolve_tdse_grid( + grid: TimeGrid, responses: Sequence[object], settings: SimulationSettings +) -> tuple[TimeGrid, SimulationSettings]: + """Resolve the response lattice once, before building the optical input. + + - Returns: resolved grid and settings with a matching fine temporal collar. + """ + atomic = [one for one in responses if isinstance(one, TDSEGasResponse)] + if not atomic: + if settings.tdse_execution is not None: + raise DriverError("tdse_execution requires a TDSEGasResponse") + return grid, settings + if len(atomic) != 1 or len(responses) != 1: + raise DriverError("TDSE owns the complete electronic response") + if settings.tdse_execution is None: + raise DriverError("TDSE requires explicit tdse_execution settings") + refined = refine_response_grid(grid, atomic[0].sampling) + return refined, _refined_tdse_collar(settings, grid, refined) + + +def _check_tdse_execution( + simulation: "Simulation", settings: SimulationSettings +) -> None: + """Refuse unsupported execution choices before any atomic preparation.""" + atomic = any(isinstance(one, TDSEGasResponse) for one in simulation.responses) + if not atomic: + if settings.tdse_execution is not None: + raise DriverError("tdse_execution requires a TDSEGasResponse") + return + if ( + settings.tdse_execution is None + or settings.backend != "numpy_ref" + or not isinstance(settings.integrator, Multistep) + ): + raise DriverError( + "TDSE requires explicit NumPy, Multistep and tdse_execution settings" + ) + if simulation.noise is not None and simulation.noise.model == "modified": + raise DriverError("TDSE refuses noise='modified'; use 'input' or None") + if settings.schedule is not None or settings.interpolate_planes: + raise DriverError("TDSE requires adaptive Adams with landed save planes") + if _scheme(settings) != "nonlinear": + raise DriverError("TDSE requires the in-stage temporal collar") + if ( + simulation.spatial_grid is not None + or simulation.general is not None + or simulation.wall is not None + ): + raise DriverError("TDSE requires the uncoupled modal capillary route") + + +def _refined_tdse_collar( + settings: SimulationSettings, parent: TimeGrid, refined: TimeGrid +) -> SimulationSettings: + """Resample only a recognized analytic collar on the finer response lattice. + + Each rate is rebuilt from its own length (SEP 22 section 5.4): the spectral rate + and the band from the reference length, the temporal rates from the collar's + in-stage length, or as zeros for a spectral-only collar. + + - ``settings``: original absorber definition and its physical lengths in m. + """ + boundary = settings.absorber + if boundary is None or boundary.temporal_rate_fine.shape == (refined.n_to,): + return settings + original = collar(parent, boundary_length=boundary.reference_length) + length = boundary.in_stage_length + + def temporal(grid: TimeGrid, axis: np.ndarray) -> np.ndarray: + """Return the collar's temporal rate on a time axis of a grid, 1/m. + + - ``axis``: its coarse or fine time axis, s. + - Returns: the bounded profile at the in-stage length, or zeros for a + spectral-only collar. + """ + if not boundary.temporal: + return np.zeros(axis.size) + return temporal_collar_rate(axis, grid.t_collar, length) + + expected = { + "spectral_rate": original.spectral_rate, + "band_mask": original.band_mask, + "temporal_rate": temporal(parent, parent.t), + "temporal_rate_fine": temporal(parent, parent.t_fine), + } + if not all( + np.array_equal(getattr(boundary, name), value) + for name, value in expected.items() + ): + raise DriverError( + "TDSE refinement requires a standard analytic collar or a custom " + "absorber already sampled on the refined response grid" + ) + return dataclasses.replace( + settings, + absorber=dataclasses.replace( + boundary, temporal_rate_fine=temporal(refined, refined.t_fine) + ), + ) + + +@dataclasses.dataclass(frozen=True) +class _TDSEEntry: + """Hold a TDSE capillary run's resolved response, resources and defaults. + + Attributes + ---------- + response : TDSEGasResponse + The declared response, its atomic window's duration resolved. + execution : TDSEExecution + Atomic runtime resources. + backend : str + The resolved backend, ``numpy_ref``. + step_planes : int + Resolved natural-step save interval. + n_save : int or None + Resolved requested-plane count; 2 (the entrance and the end) when neither + `save_planes` nor `n_save` was given. + record : dict + The resolved defaults, recorded in the run's configuration. + """ + + response: TDSEGasResponse + execution: TDSEExecution + backend: str + step_planes: int + n_save: int | None + record: dict[str, JsonValue] + + +def _tdse_entry( + tdse: TDSEGasResponse | None, + execution: TDSEExecution | None, + *, + trange: float | None, + kerr: bool, + mode_average: bool, + unset: Mapping[str, object], + backend: str, + step_planes: int | None, + save_planes: Sequence[float] | np.ndarray | None, + n_save: int | None, +) -> _TDSEEntry | None: + """Resolve the ``tdse=`` capillary keywords (SEP 20 sections 5.2 and 5.8). + + - ``tdse``: the declared response, or None for a rate-based run. + - ``trange``: requested time window in s, the default atomic-window duration. + - ``kerr``: the Kerr switch, which must stay true beside a TDSE response. + - ``mode_average``: the mode-averaged switch, which must stay false beside a TDSE + response: its translation needs the resolved radial quadrature. + - ``unset``: Electronic-response keywords that must be left unset beside it. + - ``save_planes``: requested save positions in m. + - Returns: the resolution, or None without `tdse`. + """ + if tdse is None: + if execution is not None: + raise DriverError( + "tdse_execution requires a TDSEGasResponse", + parameter=("tdse_execution", "tdse"), + rule="tdse-execution-needs-tdse", + ) + return None + if not isinstance(tdse, TDSEGasResponse): + raise DriverError("tdse must be a TDSEGasResponse", parameter="tdse") + given = [name for name, value in unset.items() if value is not None] + if not kerr: + given.append("kerr=False") + if mode_average: + given.append("mode_average=True") + if given: + raise DriverError( + f"the TDSE response owns the electronic response; leave " + f"{', '.join(given)} unset beside tdse=", + parameter=("tdse", *(name.removesuffix("=False") for name in given)), + rule="tdse-owns-electronic", + ) + response = tdse + window = tdse.atomic_window + duration_source = None + if isinstance(window, TDSEAtomicWindow): + duration_source = "explicit" + if window.duration is None: + if trange is None: + raise DriverError( + "the atomic window's duration is unresolved: give trange or " + "TDSEAtomicWindow(duration=...)", + parameter=("tdse", "trange"), + ) + response = dataclasses.replace( + tdse, atomic_window=dataclasses.replace(window, duration=trange) + ) + duration_source = "trange" + resolved_backend = "numpy_ref" if backend == "auto" else backend + if backend == "auto": + logger.info("backend='auto' resolves to numpy_ref for a TDSE response") + final_only = save_planes is None and n_save is None + resolved_steps = 1 if step_planes is None else step_planes + record: dict[str, JsonValue] = { + "model": tdse.model, + "backend": {"requested": backend, "resolved": resolved_backend}, + "step_planes": {"requested": step_planes, "resolved": resolved_steps}, + "requested_planes": "final_only" if final_only else "explicit", + "atomic_window_duration": duration_source, + "execution": "default" if execution is None else "explicit", + } + return _TDSEEntry( + response=response, + execution=TDSEExecution() if execution is None else execution, + backend=resolved_backend, + step_planes=resolved_steps, + n_save=2 if final_only else n_save, + record=record, + ) + + +def _entry_overrides[P, R]( # noqa: PLR0917 - called positionally + atomic: _TDSEEntry | None, + plasma: P, + raman: R, + backend: str, + n_save: int | None, + step_planes: int | None, +) -> tuple[_TDSEEntry | None, P, R, str, int | None, int]: + """Return a TDSE entry with the capillary settings it overrides, or the originals. + + - ``step_planes``: requested natural-step interval; ``None`` is 0 without TDSE. + - Returns: the entry, the plasma and Raman switches, the backend, the plane count + and the step interval. + """ + if atomic is None: + steps = 0 if step_planes is None else step_planes + return None, plasma, raman, backend, n_save, steps + # False is a member of both switches' declared types. + off = cast("P", False), cast("R", False) + return atomic, *off, atomic.backend, atomic.n_save, atomic.step_planes + + +def _tdse_stats(spec: StatsSpec) -> StatsSpec: + """Remove the statistics a TDSE response cannot supply from a default spec.""" + return dataclasses.replace( + spec, + names=tuple( + name for name in spec.names if name not in TDSE_UNAVAILABLE_STATISTICS + ), + ) + + +def _warn_tdse_cost(quadrature: TransverseQuadrature | None) -> None: + """Warn once when a TDSE run integrates over a many-node radial rule.""" + if quadrature is None or quadrature.radial_rule in ( + "field_scale", + "field_scale_radau", + ): + return + warnings.warn( + f"the TDSE response runs on the {quadrature.radial_rule!r} radial rule with " + f"{quadrature.n_nodes} nodes; each node is one atom in every atomic call, so " + "the atomic cost is proportional to it. A field-scale rule " + "(radial_rule='field_scale', a few nodes) integrates the HE11 response at a " + "fraction of the cost (docs/guide/waveguides/radial-quadrature.md)", + TDSECostWarning, + stacklevel=3, + ) diff --git a/src/solprop/recipe/metadata.py b/src/solprop/recipe/metadata.py index 7223b3c0..5fb7af50 100644 --- a/src/solprop/recipe/metadata.py +++ b/src/solprop/recipe/metadata.py @@ -3166,7 +3166,7 @@ def parameters(route: str) -> Mapping[str, Parameter]: if route in _TYPES: return _type_parameters(route, _TYPES[route]) if route == "execution": - from solprop.driver.simulation import ( # noqa: PLC0415 - layer D + from solprop.driver.settings import ( # noqa: PLC0415 - layer D SimulationSettings, ) diff --git a/src/solprop/recipe/provenance.py b/src/solprop/recipe/provenance.py index 0f471da2..5e5720f3 100644 --- a/src/solprop/recipe/provenance.py +++ b/src/solprop/recipe/provenance.py @@ -110,7 +110,7 @@ def _used( if build.get("provider") is not None: # A general guide takes its physics from the guide: the route refuses these # beside a provider, so the resolved text must not name them. - from solprop.driver.simulation import ( # noqa: PLC0415 - layer above + from solprop.driver.routes.general import ( # noqa: PLC0415 - layer above _GAS_KEYWORD_DEFAULTS, ) diff --git a/src/solprop/recipe/registry.py b/src/solprop/recipe/registry.py index 3a3451f7..b0c40ddd 100644 --- a/src/solprop/recipe/registry.py +++ b/src/solprop/recipe/registry.py @@ -31,7 +31,7 @@ import numpy as np from solprop.boundaries import SpatialAbsorber, absorber -from solprop.driver.simulation import SimulationSettings +from solprop.driver.settings import SimulationSettings from solprop.grid import ( EnvelopeGrid, FieldGrid, diff --git a/src/solprop/sol.py b/src/solprop/sol.py index b0e9b5d0..1f65935a 100644 --- a/src/solprop/sol.py +++ b/src/solprop/sol.py @@ -51,7 +51,8 @@ prop_waveguide, ) from solprop.driver.scan import Scan, ScanPoint, run_scan -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.driver.tdse import HaccPreset, hacc_basis, hacc_preset from solprop.driver.tdse_consistency import ConsistencyResult, check_consistency from solprop.exceptions import MaterialRangeWarning diff --git a/tests/backends/test_compiled_multistep.py b/tests/backends/test_compiled_multistep.py index e5d4bb4d..9df8b6e6 100644 --- a/tests/backends/test_compiled_multistep.py +++ b/tests/backends/test_compiled_multistep.py @@ -26,7 +26,8 @@ from solprop.backends.numpy_ref.passes import apply_hard_mask from solprop.backends.session import Start from solprop.boundaries import absorber -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.exceptions import BackendError from solprop.grid import EnvelopeGrid, FieldGrid from solprop.noise import modified_noise diff --git a/tests/backends/test_dense_output.py b/tests/backends/test_dense_output.py index 430a1435..65a2a16b 100644 --- a/tests/backends/test_dense_output.py +++ b/tests/backends/test_dense_output.py @@ -21,7 +21,8 @@ from sessions.runs import run as session_run from solprop.backends import available from solprop.backends.numpy_ref import NumpyRefBackend -from solprop.driver.simulation import Simulation, _interpolated +from solprop.driver.resume import _interpolated +from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError, OutputError, TranslationError from solprop.grid import EnvelopeGrid from solprop.log import NullReporter diff --git a/tests/backends/test_flux_gradients.py b/tests/backends/test_flux_gradients.py index a87f2318..a6de64f3 100644 --- a/tests/backends/test_flux_gradients.py +++ b/tests/backends/test_flux_gradients.py @@ -11,7 +11,8 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from solprop.backends.spatial import collar_field_fraction from solprop.constants import C -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse from solprop.nonlinear.optmat_rates import OptmatRate diff --git a/tests/backends/test_flux_paths.py b/tests/backends/test_flux_paths.py index 1058fcf1..bd7b07ba 100644 --- a/tests/backends/test_flux_paths.py +++ b/tests/backends/test_flux_paths.py @@ -12,7 +12,8 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from solprop.adiabatic import AdiabaticAccuracy -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.exceptions import MaterialError, TranslationError from solprop.grid import EnvelopeGrid, RadialGrid from solprop.output.schema import serialise diff --git a/tests/backends/test_held_plasma_contract.py b/tests/backends/test_held_plasma_contract.py index 2bf9f4da..f817fd03 100644 --- a/tests/backends/test_held_plasma_contract.py +++ b/tests/backends/test_held_plasma_contract.py @@ -13,7 +13,8 @@ from gated_source import _gated_source from solprop.backends import registry from solprop.backends.numpy_ref import NumpyRefBackend -from solprop.driver.simulation import _open_output, metadata_for +from solprop.driver.metadata import metadata_for +from solprop.driver.resume import _open_output from solprop.exceptions import DriverError, OutputError, TranslationError from solprop.output import HDF5Output, check_cache, resume from solprop.output.schema import SavedPlane diff --git a/tests/backends/test_multistep_march.py b/tests/backends/test_multistep_march.py index 3800eb2d..e6f1d5a7 100644 --- a/tests/backends/test_multistep_march.py +++ b/tests/backends/test_multistep_march.py @@ -17,7 +17,8 @@ from legacy_geometry import LEGACY_GRID from solprop.backends.numpy_ref import NumpyRefBackend from solprop.backends.registry import resolve_name -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.exceptions import ( BackendError, ResponseBudgetError, diff --git a/tests/backends/test_on_axis_statistics.py b/tests/backends/test_on_axis_statistics.py index fab375c7..8e8da3a2 100644 --- a/tests/backends/test_on_axis_statistics.py +++ b/tests/backends/test_on_axis_statistics.py @@ -31,7 +31,7 @@ from solprop.backends.numpy_ref import NumpyRefBackend from solprop.backends.populations import axis_node from solprop.driver import Simulation -from solprop.driver.simulation import _warn_sampled_peak +from solprop.driver.routes.hollow import _warn_sampled_peak from solprop.exceptions import ( BackendError, DriverError, diff --git a/tests/conftest.py b/tests/conftest.py index e0573d0e..71010b5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,10 +57,10 @@ import convergence_records # noqa: E402 - `tests/` reaches sys.path with this file import shards # noqa: E402 - test helper beside conftest import solprop # noqa: E402, F401 - imported after the hook so the package is checked too +import solprop.driver.settings as driver_settings # noqa: E402 - needs the hook above from distribution import remember_nodeid # noqa: E402 - test helper beside conftest from solprop import _process # noqa: E402 - needs the hook above from solprop.backends import registry # noqa: E402 - needs the hook above -from solprop.driver import simulation # noqa: E402 - needs the hook above # NumPy's OpenBLAS on Windows on Arm crashes or hangs in small linear solves when a # thread variable sizes it at load (solprop._process), so that lane's task sets none, @@ -89,7 +89,7 @@ def _resolve_for_tests(backend="auto"): if _NAMED_BACKEND and importlib.util.find_spec("solprop_native") is None: registry.resolve_name = _resolve_for_tests - simulation.resolve_name = _resolve_for_tests + driver_settings.resolve_name = _resolve_for_tests @pytest.fixture(autouse=True) diff --git a/tests/driver/test_absorber_keywords.py b/tests/driver/test_absorber_keywords.py index 86c9f357..e9b14f40 100644 --- a/tests/driver/test_absorber_keywords.py +++ b/tests/driver/test_absorber_keywords.py @@ -21,7 +21,7 @@ from solprop.boundaries import absorber, custom_absorber, no_absorber from solprop.driver import Simulation, SimulationSettings -from solprop.driver.simulation import _spatial_default, _with_boundary +from solprop.driver.settings import _spatial_default, _with_boundary from solprop.exceptions import DriverError from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, RadialGrid from solprop.modes import PlanarGrid, SlabMode diff --git a/tests/driver/test_capillary_components.py b/tests/driver/test_capillary_components.py index 7cda1fa3..75bb8b94 100644 --- a/tests/driver/test_capillary_components.py +++ b/tests/driver/test_capillary_components.py @@ -203,7 +203,7 @@ def test_a_wall_beside_a_two_component_set_resolves_both_components(caplog): # contracts the channel axis # of the one-component state and never reads the synthesis. So a wall keeps the # set's inference, and nothing is logged. - with caplog.at_level(logging.WARNING, logger="solprop.driver.simulation"): + with caplog.at_level(logging.WARNING, logger="solprop.driver"): simulation = _capillary( _family("TE", 0), wall=CoherentWall(impedance="te_tm"), plasma=False ) diff --git a/tests/driver/test_coherent_capillary.py b/tests/driver/test_coherent_capillary.py index c0b3f9d3..b4c1d6ad 100644 --- a/tests/driver/test_coherent_capillary.py +++ b/tests/driver/test_coherent_capillary.py @@ -46,7 +46,6 @@ from solprop.driver import Simulation, prop_capillary, prop_gnlse from solprop.driver.executors import Batch, Local from solprop.driver.scan import Scan, run_scan -from solprop.driver.simulation import Pressure from solprop.exceptions import ( AdiabaticTaperWarning, BackendError, @@ -74,6 +73,7 @@ from solprop.output.hdf5 import open_file from solprop.output.memory import MemoryOutput from solprop.physdata.glasses import fused_silica +from solprop.physdata.mixtures import Pressure from solprop.physdata.sellmeier import gamma as gas_law from solprop.profiles import FromCallable, Linear, PiecewiseLinear, ZProfile from solprop.pulses import GaussianPulse, SpatialPulse diff --git a/tests/driver/test_general_modal.py b/tests/driver/test_general_modal.py index 92355266..3b78e606 100644 --- a/tests/driver/test_general_modal.py +++ b/tests/driver/test_general_modal.py @@ -67,15 +67,16 @@ refuse_mixed_guides, ) from solprop.driver.executors import Batch -from solprop.driver.run import run_problem -from solprop.driver.scan import Scan, run_scan -from solprop.driver.simulation import ( +from solprop.driver.inputs import _shot_noise +from solprop.driver.routes.general import ( _GAS_KEYWORD_DEFAULTS, _core_index, + _from_general, _in_channel, _refuse_gas_keywords, - _shot_noise, ) +from solprop.driver.run import run_problem +from solprop.driver.scan import Scan, run_scan from solprop.exceptions import ( BackendError, DriverError, @@ -250,7 +251,7 @@ def test_from_waveguide_refuses_or_names_every_keyword_it_takes(): refuse a call that passed nothing. """ taken = set(inspect.signature(Simulation.from_waveguide).parameters) - named = set(inspect.signature(Simulation._from_general).parameters) - {"refused"} + named = set(inspect.signature(_from_general).parameters) - {"cls", "refused"} assert set(GAS_KEYWORDS) == set(_GAS_KEYWORD_DEFAULTS) assert taken == named | set(_GAS_KEYWORD_DEFAULTS) signature = inspect.signature(Simulation.from_waveguide).parameters diff --git a/tests/driver/test_initial_z_and_checkpoint.py b/tests/driver/test_initial_z_and_checkpoint.py index 5ab30447..1574e656 100644 --- a/tests/driver/test_initial_z_and_checkpoint.py +++ b/tests/driver/test_initial_z_and_checkpoint.py @@ -21,8 +21,12 @@ from solprop.backends.sessions import start_session from solprop.boundaries import absorber from solprop.driver import Simulation, SimulationSettings, run_problem +from solprop.driver import inputs as inputs_module +from solprop.driver import resume as resume_module +from solprop.driver import runner as runner_module from solprop.driver import simulation as simulation_module from solprop.driver import tdse as runtime_module +from solprop.driver import tdse_run as tdse_run_module from solprop.exceptions import ( DriverError, ResponseBudgetError, @@ -522,7 +526,7 @@ def test_resume_uses_checkpoint_position_and_rejects_inconsistent_index(tmp_path backend, problem, run_request, - simulation._start(backend, problem, state), + resume_module._start(simulation, backend, problem, state), ) == 0 ) @@ -532,7 +536,7 @@ def test_resume_uses_checkpoint_position_and_rejects_inconsistent_index(tmp_path state, checkpoint=dataclasses.replace(state.checkpoint, next_save_index=1) ) with pytest.raises(DriverError, match="next_save_index"): - simulation._start(backend, problem, invalid) + resume_module._start(simulation, backend, problem, invalid) finally: backend.close() @@ -563,7 +567,7 @@ def test_ordinary_resume_without_outcome_restores_plane_and_starts_fresh_ledgers backend, problem, run_request, - simulation._start(backend, problem, state), + resume_module._start(simulation, backend, problem, state), ) == 1 ) @@ -649,7 +653,8 @@ def close(self): def refuse(*args, **kwargs): raise DriverError("manufactured backend construction failure") - monkeypatch.setattr(simulation_module, "select", refuse) + for module in (simulation_module, runner_module): + monkeypatch.setattr(module, "select", refuse) with pytest.raises(DriverError, match="construction failure"): simulation.run(progress="none") assert closed == ["binding"] @@ -782,7 +787,8 @@ def forbidden(*args, **kwargs): raise AssertionError("completed output needs no new resources") monkeypatch.setattr(runtime_module, "prepare_binding", forbidden) - monkeypatch.setattr(simulation_module, "select", forbidden) + for module in (simulation_module, runner_module): + monkeypatch.setattr(module, "select", forbidden) output = atomic_simulation.run(path, resume=True, progress="none") assert output.n_saved == 2 @@ -868,7 +874,7 @@ def test_a_logical_ledger_continues_under_another_integrator(): state = ResumeState( simulation.metadata(), saved, 1, StepHistory.empty(), outcome=outcome ) - assert simulation_module._validate_resume(problem, run_request, state, None) == 0 + assert resume_module._validate_resume(problem, run_request, state, None) == 0 @pytest.mark.parametrize("case", ["outside", "field", "cap", "atomic"]) @@ -899,9 +905,7 @@ def test_resume_rejects_incompatible_state_before_backend_allocation(case): simulation.metadata(), saved, 1, StepHistory.empty(), outcome=outcome ) with pytest.raises(DriverError, match=message): - simulation_module._validate_resume( - problem, simulation._run_request(), state, None - ) + resume_module._validate_resume(problem, simulation._run_request(), state, None) @pytest.mark.parametrize("case", ["unused", "mixed", "missing"]) @@ -951,7 +955,7 @@ def test_tdse_settings_revalidation_refuses_unsupported_execution( simulation.wall = object() message = "uncoupled modal capillary" with pytest.raises(DriverError, match=message): - simulation_module._check_tdse_execution(simulation, settings) + tdse_run_module._check_tdse_execution(simulation, settings) class LegacyOutput: @@ -1167,7 +1171,7 @@ def forbidden(*args, **kwargs): backend, problem, run_request, - simulation._start(backend, problem, state), + resume_module._start(simulation, backend, problem, state), ) == 0 ) @@ -1243,13 +1247,14 @@ def test_spatial_resume_retains_physical_field_without_modal_normalisation(monke def forbidden(*args, **kwargs): raise AssertionError("spatial physical fields have no modal power weight") - monkeypatch.setattr(simulation_module, "power_amplitude", forbidden) + for module in (inputs_module, resume_module): + monkeypatch.setattr(module, "power_amplitude", forbidden) assert ( _loaded( backend, problem, run_request, - simulation._start(backend, problem, state), + resume_module._start(simulation, backend, problem, state), ) == 0 ) diff --git a/tests/driver/test_interface_defaults.py b/tests/driver/test_interface_defaults.py index 26d59f77..70189787 100644 --- a/tests/driver/test_interface_defaults.py +++ b/tests/driver/test_interface_defaults.py @@ -18,26 +18,24 @@ from modal_provider_cases import GENERAL_Z_END, general_grid, general_guide from solprop.driver import Simulation, SimulationSettings, interface from solprop.driver.defaults import ( - ROUTE_DEFAULTS, - default_integrator, - default_rtol, - default_scheme, - resolve_noise, - route_defaults, -) -from solprop.driver.simulation import ( CAPILLARY_RTOL, DEFAULT_RTOL, FREE_ATOL, FREE_NOISE, FREE_RTOL, + ROUTE_DEFAULTS, SLAB_NOISE, SPATIAL_ATOL, SPATIAL_RTOL, TDSE_NOISE, WAVEGUIDE_RTOL, - _admit_tdse_execution, + default_integrator, + default_rtol, + default_scheme, + resolve_noise, + route_defaults, ) +from solprop.driver.settings import _admit_tdse_execution from solprop.exceptions import DriverError, SolpropWarning from solprop.grid import EnvelopeGrid, FreeGrid, LineGrid, RadialGrid from solprop.modes import MarcatiliMode diff --git a/tests/driver/test_low_storage_settings.py b/tests/driver/test_low_storage_settings.py index 594fcd2a..48e4d2ad 100644 --- a/tests/driver/test_low_storage_settings.py +++ b/tests/driver/test_low_storage_settings.py @@ -8,7 +8,7 @@ from solprop.backends.registry import resolve_name from solprop.driver import SimulationSettings -from solprop.driver.simulation import LOW_STORAGE_BACKENDS +from solprop.driver.settings import LOW_STORAGE_BACKENDS from solprop.exceptions import DriverError from solprop.stepper import FixedSchedule, LowStorageRungeKutta, PIController diff --git a/tests/driver/test_material_manifest.py b/tests/driver/test_material_manifest.py index bf0423b1..a8c1c0d8 100644 --- a/tests/driver/test_material_manifest.py +++ b/tests/driver/test_material_manifest.py @@ -32,8 +32,8 @@ from solprop.driver import Simulation, SimulationSettings, prop_capillary from solprop.driver.executors import Local from solprop.driver.material_output import material_manifest +from solprop.driver.routes.materials import _selected_guide from solprop.driver.scan import Scan, run_scan -from solprop.driver.simulation import _selected_guide from solprop.exceptions import DiluteScalingWarning, MaterialRangeWarning, OutputError from solprop.grid import FieldGrid, FreeGrid, RadialGrid from solprop.modes import PlanarGrid, SlabMode, StepIndexMode diff --git a/tests/driver/test_noise_default.py b/tests/driver/test_noise_default.py index 80043558..0c6b4ada 100644 --- a/tests/driver/test_noise_default.py +++ b/tests/driver/test_noise_default.py @@ -11,8 +11,7 @@ import pytest from solprop.driver import Simulation, SimulationSettings, prop_gnlse -from solprop.driver.defaults import FREE_NOISE, resolve_noise -from solprop.driver.simulation import INTERIOR_LAUNCH_NOISE +from solprop.driver.defaults import FREE_NOISE, INTERIOR_LAUNCH_NOISE, resolve_noise from solprop.driver.summary import _noise from solprop.exceptions import DriverError from solprop.grid import EnvelopeGrid, RadialGrid diff --git a/tests/driver/test_optmat_rates.py b/tests/driver/test_optmat_rates.py index 192db16b..622d8d39 100644 --- a/tests/driver/test_optmat_rates.py +++ b/tests/driver/test_optmat_rates.py @@ -19,7 +19,7 @@ from rate_oracles import adk_oracle from solprop.boundaries import absorber from solprop.driver import Simulation, SimulationSettings -from solprop.driver.simulation import ( +from solprop.driver.inputs import ( _capillary_input_peak, _check_rate_inputs, default_field_range, diff --git a/tests/driver/test_parameter_paths.py b/tests/driver/test_parameter_paths.py index 7d1ab0c8..fb88b9c0 100644 --- a/tests/driver/test_parameter_paths.py +++ b/tests/driver/test_parameter_paths.py @@ -521,7 +521,7 @@ def test_the_boundary_warning_names_the_boundary_check() -> None: def test_an_inner_refusal_that_names_its_keyword_keeps_it() -> None: - from solprop.driver.simulation import _about # noqa: PLC0415 - a private seam + from solprop.driver.routes.keywords import _about # noqa: PLC0415 - a private seam with pytest.raises(DriverError) as caught, _about("pulses"): raise DriverError("refused", parameter="pulses[0].energy") diff --git a/tests/driver/test_plane_bases.py b/tests/driver/test_plane_bases.py index 03335a1d..92d82926 100644 --- a/tests/driver/test_plane_bases.py +++ b/tests/driver/test_plane_bases.py @@ -12,7 +12,7 @@ import pytest -from solprop.driver import simulation +from solprop.driver import metadata, simulation from solprop.recipe import Recipe @@ -24,7 +24,9 @@ def counting(modes, grid, z): planes.append(z) return real(modes, grid, z) - monkeypatch.setattr(simulation, "modal_basis_identity", counting) + # Both modules that evaluate a basis: the run's record and its modal identity. + for module in (metadata, simulation): + monkeypatch.setattr(module, "modal_basis_identity", counting) return planes diff --git a/tests/driver/test_prop_capillary.py b/tests/driver/test_prop_capillary.py index e04cfeaa..c1490e62 100644 --- a/tests/driver/test_prop_capillary.py +++ b/tests/driver/test_prop_capillary.py @@ -69,12 +69,11 @@ from solprop.boundaries import absorber from solprop.constants import AU_EFIELD, AU_ENERGY, EPS0, C from solprop.driver import Simulation, prop_capillary +from solprop.driver.inputs import _in_lowest_mode, default_field_range from solprop.driver.mixtures import capillary_fill -from solprop.driver.simulation import ( +from solprop.driver.routes.hollow import ( BACKEND_STATISTICS, PLASMA_STATISTICS, - _in_lowest_mode, - default_field_range, default_radial_order, ) from solprop.exceptions import ( diff --git a/tests/driver/test_prop_gnlse.py b/tests/driver/test_prop_gnlse.py index 4045f92b..78c8b60d 100644 --- a/tests/driver/test_prop_gnlse.py +++ b/tests/driver/test_prop_gnlse.py @@ -24,7 +24,8 @@ from solprop.backends.registry import select from solprop.constants import C from solprop.driver import Simulation, prop_gnlse -from solprop.driver.simulation import _dispersion_table, _gnlse_mode, _shot_noise +from solprop.driver.inputs import _shot_noise +from solprop.driver.routes.gnlse import _dispersion_table, _gnlse_mode from solprop.exceptions import DriverError, ModeError from solprop.grid import EnvelopeGrid, FieldGrid from solprop.modes import IndexTable, MarcatiliMode diff --git a/tests/driver/test_resume_gate.py b/tests/driver/test_resume_gate.py index 3be9805a..4ffe8bfc 100644 --- a/tests/driver/test_resume_gate.py +++ b/tests/driver/test_resume_gate.py @@ -22,7 +22,7 @@ import record_routes from solprop.cli import main from solprop.driver import Simulation -from solprop.driver.simulation import _check_resume_choice +from solprop.driver.resume import _check_resume_choice from solprop.exceptions import OutputError from solprop.log import NullReporter from solprop.output import check_cache, open_file, resumability diff --git a/tests/driver/test_runner.py b/tests/driver/test_runner.py index b432b249..94325352 100644 --- a/tests/driver/test_runner.py +++ b/tests/driver/test_runner.py @@ -31,7 +31,7 @@ from solprop.backends.registry import available, select from solprop.boundaries import absorber from solprop.driver import Simulation, SimulationRunner -from solprop.driver.simulation import check_derived_problem +from solprop.driver.runner import check_derived_problem from solprop.exceptions import DriverError, TranslationError from solprop.log import NullReporter from solprop.noise import NoiseRealisation, spatial_noise diff --git a/tests/driver/test_scan_kept_backend.py b/tests/driver/test_scan_kept_backend.py index ee2ad821..f6a8e986 100644 --- a/tests/driver/test_scan_kept_backend.py +++ b/tests/driver/test_scan_kept_backend.py @@ -19,7 +19,7 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from march_bounds import FIXED_ORDER, assert_points_matched -from solprop.driver import Simulation, _scan_batch, _scan_run, simulation +from solprop.driver import Simulation, _scan_batch, _scan_run, runner, simulation from solprop.driver.executors import Local, Pool from solprop.driver.scan import Scan, run_scan from solprop.grid import EnvelopeGrid @@ -93,7 +93,8 @@ def watched(*arguments, **keywords): backends.append(original(*arguments, **keywords)) return backends[-1] - monkeypatch.setattr(simulation, "select", watched) + for module in (simulation, runner): + monkeypatch.setattr(module, "select", watched) return backends diff --git a/tests/driver/test_scan_rdw.py b/tests/driver/test_scan_rdw.py index b06b66b0..76fe8ab2 100644 --- a/tests/driver/test_scan_rdw.py +++ b/tests/driver/test_scan_rdw.py @@ -17,8 +17,8 @@ from march_bounds import TIGHTER, assert_in_envelope from solprop.driver import Simulation +from solprop.driver.defaults import WAVEGUIDE_RTOL from solprop.driver.scan import Scan, run_scan -from solprop.driver.simulation import WAVEGUIDE_RTOL from solprop.processing.scans import energy_in_band from solprop.stats import StatsSpec diff --git a/tests/driver/test_simulation.py b/tests/driver/test_simulation.py index fa2a0eef..ffe08ff2 100644 --- a/tests/driver/test_simulation.py +++ b/tests/driver/test_simulation.py @@ -18,6 +18,7 @@ from solprop.constants import C from solprop.driver import Simulation, SimulationSettings +from solprop.driver import settings as settings_module from solprop.driver import simulation as simulation_module from solprop.exceptions import DriverError, MaterialRangeWarning from solprop.nonlinear.gnlse import problem_from @@ -104,7 +105,7 @@ def test_the_backend_alone_may_change_after_translation(build_simulation): def test_a_setting_that_cannot_be_compared_is_not_the_same(): """A container of arrays has no truth value; it is never taken as unchanged.""" - same = simulation_module._same_setting + same = settings_module._same_setting assert same((np.ones(2),), (np.ones(2),)) is False assert same(np.ones(2), [1.0, 1.0]) and not same(np.ones(2), np.ones(3)) assert same(1e-3, 1e-3) and not same(1e-3, 2e-3) diff --git a/tests/driver/test_snapshots.py b/tests/driver/test_snapshots.py index 27759bb1..8bf27a38 100644 --- a/tests/driver/test_snapshots.py +++ b/tests/driver/test_snapshots.py @@ -21,6 +21,7 @@ from tests.hacc_cases import alpha_law, install, plain import solprop.driver.run as run_module +import solprop.driver.runner as runner_module import solprop.driver.simulation as simulation_module from march_bounds import TIGHTER, assert_in_envelope from solprop.backends.registry import available @@ -134,7 +135,8 @@ def watched(*args): return publication monkeypatch.setattr(module, "_copy_generation", watched) - monkeypatch.setattr(simulation_module, "run_problem", run_with_live_resources) + for driver_module in (simulation_module, runner_module): + monkeypatch.setattr(driver_module, "run_problem", run_with_live_resources) simulation = _simulation() output = _run( simulation, diff --git a/tests/driver/test_specified_paths.py b/tests/driver/test_specified_paths.py index 6a9cb313..5267c849 100644 --- a/tests/driver/test_specified_paths.py +++ b/tests/driver/test_specified_paths.py @@ -29,7 +29,7 @@ from solprop.backends.registry import resolve_name from solprop.constants import C from solprop.driver import Simulation, prop_gnlse -from solprop.driver.simulation import _gnlse_mode +from solprop.driver.routes.gnlse import _gnlse_mode from solprop.exceptions import ModeError, TranslationError from solprop.grid import EnvelopeGrid from solprop.linear import Frame diff --git a/tests/driver/test_step_planes.py b/tests/driver/test_step_planes.py index 3497348d..5afccb21 100644 --- a/tests/driver/test_step_planes.py +++ b/tests/driver/test_step_planes.py @@ -22,7 +22,7 @@ from march_bounds import TIGHTER, assert_in_envelope from solprop.backends import available from solprop.driver import Simulation -from solprop.driver.simulation import _interpolated +from solprop.driver.resume import _interpolated from solprop.exceptions import TranslationError from solprop.log import NullReporter from solprop.output.hdf5 import resume diff --git a/tests/driver/test_stepindex_gnlse.py b/tests/driver/test_stepindex_gnlse.py index 6ac8b89d..58c5f51f 100644 --- a/tests/driver/test_stepindex_gnlse.py +++ b/tests/driver/test_stepindex_gnlse.py @@ -10,7 +10,7 @@ from solprop.constants import C from solprop.driver import Simulation, SimulationSettings, prop_gnlse -from solprop.driver.simulation import default_collar +from solprop.driver.settings import default_collar from solprop.exceptions import MaterialRangeWarning from solprop.grid import EnvelopeGrid from solprop.modes import IndexTable, StepIndexMode diff --git a/tests/driver/test_summary.py b/tests/driver/test_summary.py index 0ab96595..d3157dda 100644 --- a/tests/driver/test_summary.py +++ b/tests/driver/test_summary.py @@ -16,8 +16,8 @@ from solprop.backends.session import Start from solprop.driver import Simulation, SimulationSettings +from solprop.driver.metadata import metadata_for from solprop.driver.run import run_problem -from solprop.driver.simulation import metadata_for from solprop.driver.summary import ( RunSummary, _code, diff --git a/tests/driver/test_tdse_entry.py b/tests/driver/test_tdse_entry.py index b81b62b0..a19a52af 100644 --- a/tests/driver/test_tdse_entry.py +++ b/tests/driver/test_tdse_entry.py @@ -16,11 +16,11 @@ from tests.driver.test_sep12_sep14_integration import _Binding from tests.hacc_cases import hacc_response, plain, preset_basis -import solprop.driver.simulation as simulation_module +import solprop.driver.tdse_run as tdse_run_module from record_numbers import assert_record_numbers, record_skeleton, skeleton_hash from solprop.driver import Simulation, prop_capillary from solprop.driver import tdse as runtime -from solprop.driver.simulation import SimulationSettings +from solprop.driver.settings import SimulationSettings from solprop.exceptions import ( DriverError, MaterialRangeWarning, @@ -83,7 +83,7 @@ def _capillary(**changes): def test_tdse_keyword_builds_the_one_response_run_in_one_step(caplog): response = _sae() execution = TDSEExecution(max_atomic_calls=7) - with caplog.at_level(logging.INFO, logger="solprop.driver.simulation"): + with caplog.at_level(logging.INFO, logger="solprop.driver.tdse_run"): simulation = _capillary(tdse=response, tdse_execution=execution) assert simulation.responses == (response,) assert simulation.settings.tdse_execution == execution @@ -167,7 +167,7 @@ def test_tdse_execution_alone_and_a_foreign_record_are_refused(): with pytest.raises(DriverError, match="tdse_execution requires"): _capillary(tdse_execution=TDSEExecution()) # The record's own check, beneath the test session's annotation checker. - entry = inspect.unwrap(simulation_module._tdse_entry) + entry = inspect.unwrap(tdse_run_module._tdse_entry) with pytest.raises(DriverError, match="must be a TDSEGasResponse"): entry( object(), diff --git a/tests/driver/test_tdse_hacc_run.py b/tests/driver/test_tdse_hacc_run.py index c3f3b132..6a262add 100644 --- a/tests/driver/test_tdse_hacc_run.py +++ b/tests/driver/test_tdse_hacc_run.py @@ -269,12 +269,12 @@ def test_a_hacc_run_needs_a_binding_with_a_diagnostic_row(fake, monkeypatch): def test_a_failed_refusal_record_keeps_the_consistency_error_primary( fake, monkeypatch, caplog ): - import solprop.driver.simulation as simulation_module # noqa: PLC0415 + import solprop.driver.tdse_run as tdse_run_module # noqa: PLC0415 def broken(*args, **kwargs): raise OutputError("disk full") - monkeypatch.setattr(simulation_module, "open_run_record", broken) + monkeypatch.setattr(tdse_run_module, "open_run_record", broken) fake.response = alpha_law(0.5) with pytest.raises(TDSEConsistencyError, match="refused"): _run(_simulation()) diff --git a/tests/driver/test_temperature_paths.py b/tests/driver/test_temperature_paths.py index 9f3d532c..344e9e08 100644 --- a/tests/driver/test_temperature_paths.py +++ b/tests/driver/test_temperature_paths.py @@ -16,7 +16,7 @@ from solprop.boundaries import absorber from solprop.constants import ROOM_TEMPERATURE from solprop.driver import Simulation -from solprop.driver.simulation import _refuse_gas_keywords +from solprop.driver.routes.general import _refuse_gas_keywords from solprop.exceptions import ( DriverError, MaterialError, diff --git a/tests/driver/test_waveguide.py b/tests/driver/test_waveguide.py index 409d1766..3b5cab48 100644 --- a/tests/driver/test_waveguide.py +++ b/tests/driver/test_waveguide.py @@ -14,7 +14,8 @@ from march_bounds import FIXED_ORDER, STATISTIC_FACTOR, assert_matched, simulation_bound from solprop.driver import Simulation, prop_waveguide -from solprop.driver.simulation import WAVEGUIDE_RTOL +from solprop.driver.defaults import WAVEGUIDE_RTOL +from solprop.driver.routes.hollow import _from_hollow from solprop.exceptions import ( DriverError, MaterialRangeWarning, @@ -342,7 +343,8 @@ def test_waveguide_mixture_and_geometry_paths_are_rebound(): problem = simulation.translate() assert problem.transform.n_modes == 1 with pytest.raises(DriverError, match="requires supplied"): - Simulation._from_hollow( + _from_hollow( + Simulation, None, 0.001, "Ar", diff --git a/tests/modes/test_planar_grid.py b/tests/modes/test_planar_grid.py index fcaf6bc7..8eaca41d 100644 --- a/tests/modes/test_planar_grid.py +++ b/tests/modes/test_planar_grid.py @@ -11,7 +11,7 @@ from numpy.testing import assert_allclose, assert_array_equal from solprop.constants import EPS0, C -from solprop.driver.simulation import _capillary_quadrature +from solprop.driver.routes.hollow import _capillary_quadrature from solprop.exceptions import GridError, ModeError from solprop.grid import FreeGrid from solprop.modes import ( diff --git a/tests/output/test_snapshot_failures.py b/tests/output/test_snapshot_failures.py index 855eab49..f215c815 100644 --- a/tests/output/test_snapshot_failures.py +++ b/tests/output/test_snapshot_failures.py @@ -365,7 +365,7 @@ def test_snapshot_admission_rejects_invalid_controller_and_batch(working): def test_run_cleanup_retains_primary_failure_and_attempts_every_resource(primary): import inspect # noqa: PLC0415 - fault injection bypasses resource protocols - from solprop.driver.simulation import _close_run # noqa: PLC0415 + from solprop.driver.tdse_run import _close_run # noqa: PLC0415 events = [] @@ -564,7 +564,7 @@ def failed(*args, **kwargs): def test_archive_cleanup_preserves_a_preparation_error(primary): import inspect # noqa: PLC0415 - inject a failed resource into private cleanup - from solprop.driver.simulation import _close_tdse_archive # noqa: PLC0415 + from solprop.driver.tdse_run import _close_tdse_archive # noqa: PLC0415 def failed(): raise TDSEArchiveError("archive close failed") diff --git a/tests/recipe/test_metadata.py b/tests/recipe/test_metadata.py index c3f68e30..cf6540df 100644 --- a/tests/recipe/test_metadata.py +++ b/tests/recipe/test_metadata.py @@ -23,7 +23,7 @@ prop_radial, prop_waveguide, ) -from solprop.driver.simulation import SimulationSettings +from solprop.driver.settings import SimulationSettings from solprop.exceptions import DriverError from solprop.modes import MarcatiliMode, ModeCollection from solprop.recipe import metadata diff --git a/tests/recipe/test_provenance.py b/tests/recipe/test_provenance.py index c312658f..d41251e1 100644 --- a/tests/recipe/test_provenance.py +++ b/tests/recipe/test_provenance.py @@ -12,7 +12,7 @@ import numpy as np import pytest -from solprop.driver.simulation import SimulationSettings +from solprop.driver.settings import SimulationSettings from solprop.exceptions import MaterialRangeWarning, SolpropWarning from solprop.log import NullReporter from solprop.output import MemoryOutput diff --git a/tests/sessions/test_destination_export.py b/tests/sessions/test_destination_export.py index 8c716a17..325b3d1a 100644 --- a/tests/sessions/test_destination_export.py +++ b/tests/sessions/test_destination_export.py @@ -37,8 +37,8 @@ from solprop.backends.export import ExportRecipe, apply_recipe from solprop.backends.host.session import HostSession from solprop.backends.registry import available, select +from solprop.driver.metadata import metadata_for from solprop.driver.run import run_problem -from solprop.driver.simulation import metadata_for from solprop.log import NullReporter from solprop.output import CallbackOutput, HDF5Output, MemoryOutput, open_file from solprop.output.schema import Output, SavedPlane, StoredField, stored_spectrum diff --git a/tests/sessions/test_native_session_monitor.py b/tests/sessions/test_native_session_monitor.py index 6d7cb993..f7a07ce9 100644 --- a/tests/sessions/test_native_session_monitor.py +++ b/tests/sessions/test_native_session_monitor.py @@ -488,9 +488,9 @@ def _through_serve(simulation: Simulation, kind: str, *, error: bool = False) -> The production selection of the native session is not wired yet: the loop is handed the session directly, with the context ``run_problem`` would give it. """ + from solprop.driver.metadata import metadata_for # noqa: PLC0415 from solprop.driver.run import field_retention # noqa: PLC0415 from solprop.driver.service import ServiceContext, serve # noqa: PLC0415 - from solprop.driver.simulation import metadata_for # noqa: PLC0415 from solprop.log import resolve_reporter # noqa: PLC0415 from solprop.output import MemoryOutput # noqa: PLC0415 diff --git a/tests/sessions/test_service_loop.py b/tests/sessions/test_service_loop.py index e9e20448..2155d643 100644 --- a/tests/sessions/test_service_loop.py +++ b/tests/sessions/test_service_loop.py @@ -18,8 +18,8 @@ from solprop.backends.host.session import HostSession from solprop.backends.registry import select from solprop.backends.session import StopToken +from solprop.driver.metadata import metadata_for from solprop.driver.run import run_problem -from solprop.driver.simulation import metadata_for from solprop.exceptions import StopRequested from solprop.log import NullReporter from solprop.output import CallbackOutput, MemoryOutput diff --git a/tests/test_benchmark_cases.py b/tests/test_benchmark_cases.py index 6cc104d9..e06f036a 100644 --- a/tests/test_benchmark_cases.py +++ b/tests/test_benchmark_cases.py @@ -13,7 +13,7 @@ from benchmarks.records import SuiteError from benchmarks.registry import CASES, SEED, STEPPERS, select -from solprop.driver.simulation import CAPILLARY_RTOL, DEFAULT_RTOL +from solprop.driver.defaults import CAPILLARY_RTOL, DEFAULT_RTOL from solprop.exceptions import MaterialRangeWarning, SolpropWarning from solprop.nonlinear.responses import PlasmaResponse from solprop.pulses import SechPulse diff --git a/tests/test_collar_length.py b/tests/test_collar_length.py index 8f445dec..efcd6400 100644 --- a/tests/test_collar_length.py +++ b/tests/test_collar_length.py @@ -24,7 +24,7 @@ temporal_collar_rate, ) from solprop.driver import SimulationSettings -from solprop.driver.simulation import _refined_tdse_collar +from solprop.driver.tdse_run import _refined_tdse_collar from solprop.exceptions import DriverError, GridError from solprop.grid import FieldGrid from solprop.nonlinear.tdse import TDSESampling @@ -302,7 +302,7 @@ def test_the_tdse_refinement_rebuilds_each_rate_from_its_own_length(grid, kind): def test_a_spectral_only_collar_has_no_walk_off_to_check(): - from solprop.driver.simulation import _warn_capillary_walkoff # noqa: PLC0415 + from solprop.driver.routes.hollow import _warn_capillary_walkoff # noqa: PLC0415 from solprop.exceptions import SolpropWarning # noqa: PLC0415 from solprop.modes import MarcatiliMode # noqa: PLC0415 diff --git a/tests/test_grid.py b/tests/test_grid.py index b7373a93..30276598 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -149,7 +149,7 @@ def test_a_collar_placement_must_be_outside_or_inside(): def test_a_grid_without_room_for_a_taper_takes_the_spectral_collar_alone(field_grid): # A collar of one sample each side cannot taper: the default absorber falls back # to its spectral collar rather than refuse the run. - from solprop.driver.simulation import default_collar # noqa: PLC0415 + from solprop.driver.settings import default_collar # noqa: PLC0415 edges = field_grid.t_collar narrow = dataclasses.replace( diff --git a/tests/test_sep3_defaults.py b/tests/test_sep3_defaults.py index 37a719db..1cbe022a 100644 --- a/tests/test_sep3_defaults.py +++ b/tests/test_sep3_defaults.py @@ -21,7 +21,8 @@ import solprop.nonlinear.ionisation import solprop.physdata import solprop.physdata.selection -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.exceptions import MaterialError, MaterialStatusWarning, SolpropWarning from solprop.grid import FieldGrid, FreeGrid from solprop.modes.guide import ModalGuide diff --git a/tools/size_budget.toml b/tools/size_budget.toml index abd248a1..de75ce07 100644 --- a/tools/size_budget.toml +++ b/tools/size_budget.toml @@ -20,7 +20,6 @@ max_lines = 4000 [modules.allow] # The modules already larger, each capped at its present size so it can only shrink. # Remove an entry once its module is split below max_lines. -"src/solprop/driver/simulation.py" = 10550 "src/solprop/translate/problem.py" = 5444 "src/solprop/output/schema.py" = 5073 From d8db6541537f71110ad03289526f35969a4f17b9 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:58:10 +0000 Subject: [PATCH 10/42] Name the gas keyword table publicly for the recipe layer recipe/provenance.py reads it across packages, so it is public API of driver.routes.general rather than a private name imported from outside. Co-Authored-By: Claude Opus 5.5 --- src/solprop/driver/routes/general.py | 6 +++--- src/solprop/recipe/provenance.py | 4 ++-- tests/driver/test_general_modal.py | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/solprop/driver/routes/general.py b/src/solprop/driver/routes/general.py index 132e5cdc..fd4fea5b 100644 --- a/src/solprop/driver/routes/general.py +++ b/src/solprop/driver/routes/general.py @@ -54,7 +54,7 @@ if TYPE_CHECKING: from solprop.driver.simulation import Simulation -__all__ = [] +__all__ = ["GAS_KEYWORD_DEFAULTS"] logger = logging.getLogger(__name__) @@ -299,7 +299,7 @@ def _from_general( return _incident_launch(simulation, beam, cast("CouplingModel", coupling)) -_GAS_KEYWORD_DEFAULTS: Mapping[str, object] = { +GAS_KEYWORD_DEFAULTS: Mapping[str, object] = { "gas": None, "pressure": None, "pressure_floor": None, @@ -393,7 +393,7 @@ def _refuse_gas_keywords(supplied: Mapping[str, object]) -> None: # `from_waveguide` would silently ignore on this route, and # `test_from_waveguide_refuses_or_names_every_keyword_it_takes` locks the # two together so that adding one cannot pass unnoticed. - default = _GAS_KEYWORD_DEFAULTS[name] + default = GAS_KEYWORD_DEFAULTS[name] if _means_not_given(value, default): continue msg = ( diff --git a/src/solprop/recipe/provenance.py b/src/solprop/recipe/provenance.py index 5e5720f3..b941deff 100644 --- a/src/solprop/recipe/provenance.py +++ b/src/solprop/recipe/provenance.py @@ -111,13 +111,13 @@ def _used( # A general guide takes its physics from the guide: the route refuses these # beside a provider, so the resolved text must not name them. from solprop.driver.routes.general import ( # noqa: PLC0415 - layer above - _GAS_KEYWORD_DEFAULTS, + GAS_KEYWORD_DEFAULTS, ) values = { name: value for name, value in values.items() - if name not in _GAS_KEYWORD_DEFAULTS + if name not in GAS_KEYWORD_DEFAULTS } return values diff --git a/tests/driver/test_general_modal.py b/tests/driver/test_general_modal.py index 3b78e606..e0930f4d 100644 --- a/tests/driver/test_general_modal.py +++ b/tests/driver/test_general_modal.py @@ -69,7 +69,7 @@ from solprop.driver.executors import Batch from solprop.driver.inputs import _shot_noise from solprop.driver.routes.general import ( - _GAS_KEYWORD_DEFAULTS, + GAS_KEYWORD_DEFAULTS, _core_index, _from_general, _in_channel, @@ -252,10 +252,10 @@ def test_from_waveguide_refuses_or_names_every_keyword_it_takes(): """ taken = set(inspect.signature(Simulation.from_waveguide).parameters) named = set(inspect.signature(_from_general).parameters) - {"cls", "refused"} - assert set(GAS_KEYWORDS) == set(_GAS_KEYWORD_DEFAULTS) - assert taken == named | set(_GAS_KEYWORD_DEFAULTS) + assert set(GAS_KEYWORDS) == set(GAS_KEYWORD_DEFAULTS) + assert taken == named | set(GAS_KEYWORD_DEFAULTS) signature = inspect.signature(Simulation.from_waveguide).parameters - for name, default in _GAS_KEYWORD_DEFAULTS.items(): + for name, default in GAS_KEYWORD_DEFAULTS.items(): assert signature[name].default == default @@ -269,7 +269,7 @@ def test_an_array_valued_gas_keyword_is_refused_by_name_and_not_by_a_crash(): with pytest.raises(DriverError, match="pressure="): _refuse_gas_keywords({"pressure": np.array([1.0, 2.0])}) # And the "not given" values still pass, array-free. - _refuse_gas_keywords(dict.fromkeys(GAS_KEYWORDS) | dict(_GAS_KEYWORD_DEFAULTS)) + _refuse_gas_keywords(dict.fromkeys(GAS_KEYWORDS) | dict(GAS_KEYWORD_DEFAULTS)) def test_the_gas_route_still_needs_a_gas_and_refuses_the_guide_keywords(): From 909382b9c6290f5934e5d3edacfc2bbf088a6584 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 05:58:38 +0000 Subject: [PATCH 11/42] Replace small files through one atomic writer in solprop._durable The scan queue's state, the service's run records and the TDSE consistency cache each had a temporary-file-and-rename copy; each now encodes its JSON and calls write_atomic, which fsyncs, retries a rename a reader blocks, and syncs the directory where the queue needs it. Co-Authored-By: Claude Opus 5.5 --- docs/dev/architecture.md | 4 +- src/solprop/_durable.py | 67 ++++++++++++++++++++ src/solprop/driver/executors/_queue_state.py | 25 +------- src/solprop/driver/executors/queue.py | 2 +- src/solprop/driver/tdse_consistency.py | 13 +--- src/solprop/service/record.py | 28 +------- tests/driver/test_tdse_consistency.py | 7 +- tests/service/test_record.py | 3 +- 8 files changed, 84 insertions(+), 65 deletions(-) create mode 100644 src/solprop/_durable.py diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 02f0c565..d7555e07 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -244,7 +244,9 @@ module, drawn dashed in figure 1, to be written only for an agreed use case. `_display.py` is private: the formatter behind every class's printed block, imported by any layer and importing none. So is `_identity.py`: the canonical JSON text of a record and its SHA-256, which every stored identity is (a run's resume key, a scan's common -record, a coupling plane's basis, a TDSE basis or descriptor, a material population). +record, a coupling plane's basis, a TDSE basis or descriptor, a material population), +and `_durable.py`: a small file replaced whole, which the scan queue's state, the front +end's run records and the TDSE consistency cache are written with. The layout does not mirror Luna's module tree: what `NonlinearRHS.jl` does spans response normalisation, translation and backend passes; `Maths.jl` mostly becomes SciPy diff --git a/src/solprop/_durable.py b/src/solprop/_durable.py new file mode 100644 index 00000000..90d85f27 --- /dev/null +++ b/src/solprop/_durable.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Small files replaced whole, so that a reader never sees half of one. + +The scan queue's state, the front end's run records and the TDSE consistency cache are +each a small text file that another process may read at any moment. Each is written to a +temporary file beside it, flushed to the disk, and renamed over the old one in a single +step (:func:`write_atomic`); a scan queue also makes the rename itself durable +(:func:`sync_directory`), since its claims must survive a crash of the machine. + +Like ``_process``, this module imports nothing from the rest of the package, so any +layer may use it. +""" + +import os +import tempfile +import time +from pathlib import Path +from typing import Final + +__all__ = ["REPLACE_TRIES", "sync_directory", "write_atomic"] + +REPLACE_TRIES: Final = 50 +"""How often a rename refused by another process's open handle is tried, 20 ms apart.""" + + +def sync_directory(path: Path) -> None: + """Make the names published in a directory durable (POSIX directory fsync).""" + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_atomic(path: Path, text: str, *, durable: bool = False) -> None: + """Replace `path` with `text` in one step, its content on the disk first. + + ``durable=True`` also syncs the directory, so the new name survives a crash. + Windows refuses to replace a file another process has open (a reader of a run's + summary), so a refused rename is tried again for a moment rather than leaving the + file stale. + """ + descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + left = REPLACE_TRIES + while True: + try: + temporary.replace(path) + except PermissionError: + left -= 1 + if not left: + raise + time.sleep(0.02) + else: + break + if durable: + sync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) diff --git a/src/solprop/driver/executors/_queue_state.py b/src/solprop/driver/executors/_queue_state.py index 07ec5ab3..1941a195 100644 --- a/src/solprop/driver/executors/_queue_state.py +++ b/src/solprop/driver/executors/_queue_state.py @@ -7,13 +7,13 @@ import json import os import socket -import tempfile import time import uuid from collections.abc import Iterator, Mapping from contextlib import contextmanager from pathlib import Path +from solprop._durable import sync_directory, write_atomic from solprop._process import process_stamp from solprop.exceptions import DriverError from solprop.output._file_lock import lock, unlock @@ -32,28 +32,9 @@ def locked(path: Path) -> Iterator[None]: os.close(descriptor) -def sync_directory(path: Path) -> None: - """Make filename publication durable on a filesystem supporting directory fsync.""" - descriptor = os.open(path, os.O_RDONLY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - - def atomic_json(path: Path, value: object) -> None: - """Replace a JSON record only after its complete content has been fsynced.""" - descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - temporary = Path(name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - stream.write(_json(value)) - stream.flush() - os.fsync(stream.fileno()) - temporary.replace(path) - sync_directory(path.parent) - finally: - temporary.unlink(missing_ok=True) + """Replace a JSON record durably, its complete content on the disk first.""" + write_atomic(path, _json(value), durable=True) def read_json(path: Path) -> dict: diff --git a/src/solprop/driver/executors/queue.py b/src/solprop/driver/executors/queue.py index 92e30118..19f7750b 100644 --- a/src/solprop/driver/executors/queue.py +++ b/src/solprop/driver/executors/queue.py @@ -24,6 +24,7 @@ import h5py import numpy as np +from solprop._durable import sync_directory from solprop.driver._scan_model import Scan, ScanPoint from solprop.driver.executors._queue_state import ( atomic_json, @@ -33,7 +34,6 @@ owner, read_json, state, - sync_directory, ) from solprop.driver.executors._request import ScanRequest from solprop.driver.executors.pool import Pool diff --git a/src/solprop/driver/tdse_consistency.py b/src/solprop/driver/tdse_consistency.py index 577f227a..e12d1a63 100644 --- a/src/solprop/driver/tdse_consistency.py +++ b/src/solprop/driver/tdse_consistency.py @@ -27,7 +27,6 @@ import logging import math import os -import tempfile import time import uuid import warnings @@ -41,6 +40,7 @@ import solprop import solprop.driver.tdse as tdse_runtime +from solprop._durable import write_atomic from solprop._identity import canonical_digest from solprop.constants import EPS0, C from solprop.exceptions import ( @@ -463,16 +463,7 @@ def _read_cache( def _write_cache(path: Path, entry: Mapping[str, object]) -> None: """Write a cache entry atomically: a temporary file, then a rename.""" path.parent.mkdir(parents=True, exist_ok=True) - handle, temporary = tempfile.mkstemp( - dir=path.parent, prefix=f".{path.stem}.", suffix=".tmp" - ) - try: - with os.fdopen(handle, "w", encoding="utf-8") as stream: - json.dump(entry, stream, sort_keys=True, allow_nan=False) - Path(temporary).replace(path) - except BaseException: - Path(temporary).unlink(missing_ok=True) - raise + write_atomic(path, json.dumps(entry, sort_keys=True, allow_nan=False)) @dataclass(frozen=True) diff --git a/src/solprop/service/record.py b/src/solprop/service/record.py index b52ac064..55074218 100644 --- a/src/solprop/service/record.py +++ b/src/solprop/service/record.py @@ -30,7 +30,6 @@ import os import queue import socket -import tempfile import threading import time from collections.abc import Callable, Mapping @@ -38,6 +37,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Final +from solprop._durable import write_atomic from solprop._process import process_stamp from solprop.service.frames import encode, split_frames from solprop.service.messages import TERMINAL, Event, ServiceError @@ -89,9 +89,6 @@ HEARTBEAT: Final = 30.0 """The longest time between two rewrites of a living run's summary, s.""" -REPLACE_TRIES: Final = 50 -"""How many times a small file is put in place while a reader holds it (Windows).""" - STOP_PERIOD: Final = 0.25 """How often a run looks for its stop file, s.""" @@ -111,28 +108,7 @@ def write_json(path: Path, value: Mapping[str, object]) -> None: """ import json # noqa: PLC0415 - light, and only here - descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - temporary = Path(name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - json.dump(value, stream, indent=1, sort_keys=True) - stream.flush() - os.fsync(stream.fileno()) - # Windows refuses to replace a file another process has open (a reader of - # the summary): try again for a moment rather than leave it stale. - left = REPLACE_TRIES - while True: - try: - temporary.replace(path) - except PermissionError: - left -= 1 - if not left: - raise - time.sleep(0.02) - else: - return - finally: - temporary.unlink(missing_ok=True) + write_atomic(path, json.dumps(value, indent=1, sort_keys=True)) def _read_json(path: Path) -> dict[str, Any] | None: diff --git a/tests/driver/test_tdse_consistency.py b/tests/driver/test_tdse_consistency.py index d58b66bc..7f18d8ba 100644 --- a/tests/driver/test_tdse_consistency.py +++ b/tests/driver/test_tdse_consistency.py @@ -29,6 +29,7 @@ tiny_basis, ) +from solprop import _durable from solprop.constants import E_CHARGE, EPS0, M_E, C from solprop.driver import tdse, tdse_consistency from solprop.driver.tdse_consistency import ( @@ -612,11 +613,11 @@ def test_cache_write_is_atomic(cache, monkeypatch): path = cache / f"{first.cache_key}.json" before = path.read_text(encoding="utf-8") - def interrupted(entry, stream, **kwargs): - stream.write('{"inputs": ') + def interrupted(descriptor): + # The entry is in the temporary file, not yet on the disk nor in place. raise KeyboardInterrupt - monkeypatch.setattr(tdse_consistency.json, "dump", interrupted) + monkeypatch.setattr(_durable.os, "fsync", interrupted) with pytest.raises(KeyboardInterrupt): _check(source, refresh=True) # The reader never sees the partial file: the entry is unchanged and no diff --git a/tests/service/test_record.py b/tests/service/test_record.py index 6db3e0ed..ceb2ecaf 100644 --- a/tests/service/test_record.py +++ b/tests/service/test_record.py @@ -14,6 +14,7 @@ import numpy as np import pytest +from solprop import _durable from solprop.backends.session import StopToken from solprop.service import record from solprop.service.messages import Event, ServiceError @@ -235,7 +236,7 @@ def held(self, target): monkeypatch.setattr(Path, "replace", held) write_json(tmp_path / "s.json", {"a": 1}) assert read_status(tmp_path) is None and (tmp_path / "s.json").exists() - monkeypatch.setattr(record, "REPLACE_TRIES", 2) + monkeypatch.setattr(_durable, "REPLACE_TRIES", 2) refusals = iter([True, True]) with pytest.raises(PermissionError): write_json(tmp_path / "t.json", {"a": 1}) From ae9b0de0b608e0b09dfd881637a3f1e612273411 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:00:18 +0000 Subject: [PATCH 12/42] Pass the hollow and general routes one request each _from_hollow and _from_general take a frozen _HollowRequest or _GeneralRequest built from the public constructor's locals by keyword_request, instead of 64 and 41 keywords passed one by one. Co-Authored-By: Claude Opus 5.5 --- src/solprop/driver/routes/general.py | 216 +++++++------ src/solprop/driver/routes/hollow.py | 423 +++++++++++++------------ src/solprop/driver/routes/keywords.py | 16 +- src/solprop/driver/simulation.py | 209 ++---------- tests/driver/test_general_modal.py | 8 +- tests/driver/test_waveguide.py | 18 +- tests/processing/test_modal_metrics.py | 2 +- 7 files changed, 400 insertions(+), 492 deletions(-) diff --git a/src/solprop/driver/routes/general.py b/src/solprop/driver/routes/general.py index fd4fea5b..8556e028 100644 --- a/src/solprop/driver/routes/general.py +++ b/src/solprop/driver/routes/general.py @@ -7,6 +7,7 @@ import functools import logging from collections.abc import Mapping, Sequence +from dataclasses import dataclass from typing import TYPE_CHECKING, Literal, cast import numpy as np @@ -59,58 +60,9 @@ logger = logging.getLogger(__name__) -def _from_general( - cls: type["Simulation"], - provider: ModalGuide, - modes: Sequence[Mode], - length: float, - *, - channel: int | str | None, - incident: SpatialPulse | None, - coupling: CouplingModel | None, - kerr_form: KerrForm, - observable_rtol: float | None, - observable_atol: float | None, - wavelength: float, - wavelength_limits: tuple[float, float] | None, - trange: float | None, - grid: TimeGrid | None, - fwhm: float | None, - width: float | None, - shape: str, - energy: float | None, - peak_power: float | None, - phases: Sequence[float] | np.ndarray, - pulses: Sequence[Pulse] | None, - absorber: Absorber | Literal[False] | None, - absorber_scheme: AbsorberScheme | None, - boundary_check: BoundaryCheck | None, - noise: NoiseSetting, - seed: int | None, - rtol: float | None, - atol: float, - dz_max: float | None, - dz_init: float | None, - schedule: FixedSchedule | None, - save_planes: Sequence[float] | np.ndarray | None, - n_save: int | None, - stats: StatsSpec | None, - backend: str, - materials: MaterialSelection | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - launch: ModalLaunch | None = None, - interpolate_planes: bool = False, - step_planes: int | None = None, - polarisation_taper: bool = False, - **refused: object, -) -> "Simulation": - """Build a general modal run from a guide that solves its own fields. - - The provider half of :meth:`from_waveguide`, kept apart because it shares no - physics with the gas-filled one: no fill, no mixture, no static transverse - quadrature, no declared response list, and a launch that crosses no - normalisation boundary. Every keyword of the gas-filled path arrives in - `refused` and is refused by name if it was set. +@dataclass(frozen=True, kw_only=True) +class _GeneralRequest: + """The keywords of :func:`_from_general`, as the public constructor took them. - ``length``: guide length, m. - ``channel``: which channel the pulse enters; ``None`` takes the first. @@ -127,16 +79,71 @@ def _from_general( and ``LowStorageRungeKutta()`` replays `schedule` in three registers. - ``step_planes``: also save the end of every `step_planes`-th accepted step; ``None`` is ``0``. - - ``**refused``: every gas-filled keyword; each is refused by name when it is + - ``refused``: every gas-filled keyword, by name; each is refused when it is set. """ - _refuse_gas_keywords(refused) + + provider: ModalGuide + modes: Sequence[Mode] + length: float + channel: int | str | None + incident: SpatialPulse | None + coupling: CouplingModel | None + kerr_form: KerrForm + observable_rtol: float | None + observable_atol: float | None + wavelength: float + wavelength_limits: tuple[float, float] | None + trange: float | None + grid: TimeGrid | None + fwhm: float | None + width: float | None + shape: str + energy: float | None + peak_power: float | None + phases: Sequence[float] | np.ndarray + pulses: Sequence[Pulse] | None + absorber: Absorber | Literal[False] | None + absorber_scheme: AbsorberScheme | None + boundary_check: BoundaryCheck | None + noise: NoiseSetting + seed: int | None + rtol: float | None + atol: float + dz_max: float | None + dz_init: float | None + schedule: FixedSchedule | None + save_planes: Sequence[float] | np.ndarray | None + n_save: int | None + stats: StatsSpec | None + backend: str + materials: MaterialSelection | None = None + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None + launch: ModalLaunch | None = None + interpolate_planes: bool = False + step_planes: int | None = None + polarisation_taper: bool = False + refused: Mapping[str, object] + + +def _from_general(cls: type["Simulation"], request: _GeneralRequest) -> "Simulation": + """Build a general modal run from a guide that solves its own fields. + + The provider half of :meth:`from_waveguide`, kept apart because it shares no + physics with the gas-filled one: no fill, no mixture, no static transverse + quadrature, no declared response list, and a launch that crosses no + normalisation boundary. Every keyword of the gas-filled path arrives in the + request's ``refused`` and is refused by name if it was set. + """ + provider = request.provider + _refuse_gas_keywords(request.refused) provider = _selected_guide( - provider, MaterialSelection() if materials is None else materials + provider, + MaterialSelection() if request.materials is None else request.materials, ) for name, value in ( - ("observable_rtol", observable_rtol), - ("observable_atol", observable_atol), + ("observable_rtol", request.observable_rtol), + ("observable_atol", request.observable_atol), ): if value is not None: msg = ( @@ -150,11 +157,11 @@ def _from_general( ) raise DriverError(msg) working = _capillary_grid( - grid, - length, - wavelength, - wavelength_limits, - trange, + request.grid, + request.length, + request.wavelength, + request.wavelength_limits, + request.trange, envelope=False, thg=False, ) @@ -167,28 +174,33 @@ def _from_general( ) raise DriverError(msg) channels = tuple(provider.mode_collection.modes) - if tuple(modes) not in ((), channels): + if tuple(request.modes) not in ((), channels): msg = ( f"a general modal guide supplies its own {len(channels)} ordered " f"channel(s), validated against the provider's fields, and this " - f"call passes {len(tuple(modes))} mode(s) of its own: pass " + f"call passes {len(tuple(request.modes))} mode(s) of its own: pass " "modes=() beside provider=, or the guide's own " "provider.mode_collection.modes" ) raise DriverError(msg) - planes = _save_planes(length, save_planes, n_save) - refuse_beside(launch, channel=channel, incident=incident, coupling=coupling) + planes = _save_planes(request.length, request.save_planes, request.n_save) + refuse_beside( + request.launch, + channel=request.channel, + incident=request.incident, + coupling=request.coupling, + ) pulse = _general_pulse( - incident, - channel, - shape, - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, + request.incident, + request.channel, + request.shape, + wavelength=request.wavelength, + fwhm=request.fwhm, + width=request.width, + energy=request.energy, + peak_power=request.peak_power, + phases=request.phases, + pulses=request.pulses, ) built = (pulse.temporal if isinstance(pulse, SpatialPulse) else pulse).build( working @@ -199,8 +211,10 @@ def _from_general( working, z_max=float(np.max(planes)), omega_reference=float(working.omega0), - kerr_form=kerr_form, - selection=MaterialSelection() if materials is None else materials, + kerr_form=request.kerr_form, + selection=MaterialSelection() + if request.materials is None + else request.materials, # The pulse is built first so that the route's occupied-support basis # gate is weighted by where this run's light actually is rather than # over the whole declared band. The launch's channel distribution does @@ -217,7 +231,7 @@ def _from_general( # own, unchanged, and the cause is kept. raise DriverError(str(error)) from error beam = pulse if isinstance(pulse, SpatialPulse) else None - if (beam is None) != (coupling is None): + if (beam is None) != (request.coupling is None): msg = ( "an incident beam is coupled under a declared model: give incident= " "with coupling=CouplingModel('vector_paraxial', model_error=...), or " @@ -231,8 +245,8 @@ def _from_general( _core_index(provider.regions, working) # The launch is the coupling's, installed through `with_input` below. field0 = np.zeros((len(channels), 1, working.n_omega), dtype=np.complex128) - elif launch is None: - field0 = _in_channel(built, provider, channel) + elif request.launch is None: + field0 = _in_channel(built, provider, request.channel) else: # A declared change of basis carries a dense flux metric, under which the # shares are a coordinate split; in the provider's own coordinates the @@ -240,37 +254,37 @@ def _from_general( field0, launched = launch_pulse( # No beam, so the input is the shaped pulse. cast("Pulse", pulse), - launch, + request.launch, working, provider.mode_collection.channel_labels, metric=metric_at(route.metric_tables, 0.0), ) - boundary, scheme = _boundary(absorber, absorber_scheme, working) - controller, default = _keyword_controller("waveguide", rtol, atol) + boundary, scheme = _boundary(request.absorber, request.absorber_scheme, working) + controller, default = _keyword_controller("waveguide", request.rtol, request.atol) settings = SimulationSettings( controller=controller, _default_controller=default, _route="waveguide", save_planes=planes, - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=0 if step_planes is None else step_planes, + dz_max=request.length / 2 if request.dz_max is None else request.dz_max, + dz_init=request.length * 1e-4 if request.dz_init is None else request.dz_init, + auto_init=request.dz_init is None, + schedule=request.schedule, + integrator=request.integrator, + interpolate_planes=request.interpolate_planes, + step_planes=0 if request.step_planes is None else request.step_planes, absorber=boundary, absorber_scheme=scheme, - boundary_check=boundary_check, + boundary_check=request.boundary_check, # The route carries its nodes, its materials and its normalisation in # its own leaves: the static transverse quadrature, the component # selection, the density path and the index override are all refused # by the translation layer and are not settings of this run. - stats=_general_stats() if stats is None else stats, - polarisation_taper=polarisation_taper, - backend=backend, + stats=_general_stats() if request.stats is None else request.stats, + polarisation_taper=request.polarisation_taper, + backend=request.backend, ) - noise_model, resolution = resolve_noise("waveguide", noise, seed) + noise_model, resolution = resolve_noise("waveguide", request.noise, request.seed) draw = functools.partial(_general_noise, noise_model, working, guide=provider) simulation = cls( working, @@ -278,12 +292,12 @@ def _from_general( [], field0, settings=settings, - noise=draw(seed), + noise=draw(request.seed), noise_resolution=resolution, configuration={ "waveguide": { "modes": [ModeRecord.from_mode(mode) for mode in channels], - "length": length, + "length": request.length, "provider": dict(provider.identity), }, **({} if beam is not None else {"pulse": pulse}), @@ -291,12 +305,12 @@ def _from_general( }, general=route, guide=provider, - materials=materials, + materials=request.materials, ) - _redrawable(simulation, draw, seed) + _redrawable(simulation, draw, request.seed) if beam is None: return simulation - return _incident_launch(simulation, beam, cast("CouplingModel", coupling)) + return _incident_launch(simulation, beam, cast("CouplingModel", request.coupling)) GAS_KEYWORD_DEFAULTS: Mapping[str, object] = { diff --git a/src/solprop/driver/routes/hollow.py b/src/solprop/driver/routes/hollow.py index 346b52d2..6820c187 100644 --- a/src/solprop/driver/routes/hollow.py +++ b/src/solprop/driver/routes/hollow.py @@ -150,85 +150,9 @@ """Radial nodes per mode in the default order; see :func:`default_radial_order`.""" -def _from_hollow( # noqa: PLR0915 - the interface's keyword surface - cls: type["Simulation"], - radius: float | ZProfile | None, - length: float, - gas: Gas | str | Mapping[str, float] | Mixture, - pressure: Pressure | None = None, - *, - waveguide: bool = False, - pressure_floor: float | None = None, - vacuum_end: VacuumEnd | None = None, - wavelength: float, - wavelength_limits: tuple[float, float] | None = None, - trange: float | None = None, - grid: TimeGrid | None = None, - envelope: bool = False, - fwhm: float | None = None, - width: float | None = None, - shape: str = "gauss", - energy: float | None = None, - peak_power: float | None = None, - phases: Sequence[float] | np.ndarray = (), - pulses: Sequence[Pulse] | None = None, - launch: ModalLaunch | None = None, - modes: int | Sequence[Mode] = 1, - mode_average: bool = False, - lattice: TubeLattice | None = None, - wall: CoherentWall | None = None, - taper_coupling: bool = True, - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, - susceptibility: GasSusceptibility | None = None, - temperature: float | ZProfile | None = None, - kerr: bool = True, - thg: bool | None = None, - plasma: bool | IonisationRate | Mapping[str, bool | IonisationRate] | None = None, - preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None, - plasma_integration: IntegrationPolicy | None = None, - raman: bool | Sequence[str] | Mapping[str, bool | MolecularOptions] | None = None, - n_r: int | None = None, - n_theta: int | None = None, - radial_rule: RadialRule = "gauss_kronrod", - ionisation_field_range: tuple[float, float] | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - noise: NoiseSetting = "default", - seed: int | None = None, - rtol: float | None = None, - atol: float = WAVEGUIDE_ATOL, - dz_max: float | None = None, - dz_init: float | None = None, - schedule: FixedSchedule | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - route: Literal["capillary", "waveguide"] = "capillary", - interpolate_planes: bool = False, - step_planes: int | None = None, - save_planes: Sequence[float] | np.ndarray | None = None, - n_save: int | None = None, - stats: StatsSpec | None = None, - polarisation_taper: bool = False, - node_budget: int | None = None, - backend: str = "auto", - polarisation: str | float | None = None, - materials: MaterialSelection | None = None, - tdse: TDSEGasResponse | None = None, - tdse_execution: TDSEExecution | None = None, -) -> "Simulation": - """Build a simulation of a pulse in a gas-filled hollow waveguide. - - The shared body of :meth:`from_capillary` and :meth:`from_waveguide`. The mode - set is the first `modes` HE_1m modes of the capillary unless a set is given; the - transverse integral is a fixed quadrature rule - (``docs/guide/waveguides/mode-sets.md``); the - gas density along z is the pressure path through the equation of state, and the - same path sets the modes' core index, so a gradient fill disperses and broadens - consistently. - - See ``docs/guide/setup/running.md`` for the discussion and a worked example. +@dataclasses.dataclass(frozen=True, kw_only=True) +class _HollowRequest: + """The keywords of :func:`_from_hollow`, as the public constructor took them. - ``radius``: core radius, m, constant or along z. - ``length``: capillary length, m. @@ -409,25 +333,114 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface it is. The run records its material manifest. ``None`` (the default) is ``MaterialSelection()``. """ + + radius: float | ZProfile | None + length: float + gas: Gas | str | Mapping[str, float] | Mixture + pressure: Pressure | None = None + waveguide: bool = False + pressure_floor: float | None = None + vacuum_end: VacuumEnd | None = None + wavelength: float + wavelength_limits: tuple[float, float] | None = None + trange: float | None = None + grid: TimeGrid | None = None + envelope: bool = False + fwhm: float | None = None + width: float | None = None + shape: str = "gauss" + energy: float | None = None + peak_power: float | None = None + phases: Sequence[float] | np.ndarray = () + pulses: Sequence[Pulse] | None = None + launch: ModalLaunch | None = None + modes: int | Sequence[Mode] = 1 + mode_average: bool = False + lattice: TubeLattice | None = None + wall: CoherentWall | None = None + taper_coupling: bool = True + model: MarcatiliModel = "full" + loss: LossSpec = True + cladding: GlassIndex | complex | None = None + susceptibility: GasSusceptibility | None = None + temperature: float | ZProfile | None = None + kerr: bool = True + thg: bool | None = None + plasma: bool | IonisationRate | Mapping[str, bool | IonisationRate] | None = None + preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None + plasma_integration: IntegrationPolicy | None = None + raman: bool | Sequence[str] | Mapping[str, bool | MolecularOptions] | None = None + n_r: int | None = None + n_theta: int | None = None + radial_rule: RadialRule = "gauss_kronrod" + ionisation_field_range: tuple[float, float] | None = None + absorber: Absorber | Literal[False] | None = None + absorber_scheme: AbsorberScheme | None = None + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK + noise: NoiseSetting = "default" + seed: int | None = None + rtol: float | None = None + atol: float = WAVEGUIDE_ATOL + dz_max: float | None = None + dz_init: float | None = None + schedule: FixedSchedule | None = None + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None + route: Literal["capillary", "waveguide"] = "capillary" + interpolate_planes: bool = False + step_planes: int | None = None + save_planes: Sequence[float] | np.ndarray | None = None + n_save: int | None = None + stats: StatsSpec | None = None + polarisation_taper: bool = False + node_budget: int | None = None + backend: str = "auto" + polarisation: str | float | None = None + materials: MaterialSelection | None = None + tdse: TDSEGasResponse | None = None + tdse_execution: TDSEExecution | None = None + + +def _from_hollow( # noqa: PLR0915 - the route's whole build, keyword by keyword + cls: type["Simulation"], request: _HollowRequest +) -> "Simulation": + """Build a simulation of a pulse in a gas-filled hollow waveguide. + + The shared body of :meth:`from_capillary` and :meth:`from_waveguide`. The mode + set is the first `modes` HE_1m modes of the capillary unless a set is given; the + transverse integral is a fixed quadrature rule + (``docs/guide/waveguides/mode-sets.md``); the + gas density along z is the pressure path through the equation of state, and the + same path sets the modes' core index, so a gradient fill disperses and broadens + consistently. + + See ``docs/guide/setup/running.md`` for the discussion and a worked example. + """ + backend = request.backend + materials = request.materials + n_save = request.n_save + plasma = request.plasma + raman = request.raman + step_planes = request.step_planes + thg = request.thg # The TDSE response owns the electronic response: build no other. atomic, plasma, raman, backend, n_save, step_planes = _entry_overrides( _tdse_entry( - tdse, - tdse_execution, - trange=trange, - kerr=kerr, - mode_average=mode_average, + request.tdse, + request.tdse_execution, + trange=request.trange, + kerr=request.kerr, + mode_average=request.mode_average, unset={ "plasma": plasma, "raman": raman, - "preionisation": preionisation, - "plasma_integration": plasma_integration, - "ionisation_field_range": ionisation_field_range, + "preionisation": request.preionisation, + "plasma_integration": request.plasma_integration, + "ionisation_field_range": request.ionisation_field_range, "thg": thg, }, backend=backend, step_planes=step_planes, - save_planes=save_planes, + save_planes=request.save_planes, n_save=n_save, ), plasma, @@ -436,18 +449,18 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface n_save, step_planes, ) - if raman and not kerr: + if raman and not request.kerr: msg = ( "raman=True needs kerr=True: a medium with a Raman response has an " "electronic one too, and on a field grid the drive form reaches the " "kernel through the Kerr term (docs/guide/media/raman.md)" ) raise DriverError(msg, parameter=("raman", "kerr"), rule="raman-needs-kerr") - if polarisation is not None: + if request.polarisation is not None: msg = ( - f"polarisation={polarisation!r}: this path is scalar and propagates " - "one transverse component per mode row. Declare the polarisation " - "in the modes instead -- a rectangular or slab guide's own " + f"polarisation={request.polarisation!r}: this path is scalar and " + "propagates one transverse component per mode row. Declare the " + "polarisation in the modes instead -- a rectangular or slab guide's own " "polarisation, each partner a row of its own -- or, for a vector " "capillary run, a polarisation-resolved mode set, which is not " "validated end to end yet" @@ -456,15 +469,15 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface msg, parameter="polarisation", rule="scalar-capillary-polarisation" ) _check_wall_declaration( - wall, - model=model, - loss=loss, - lattice=lattice, - taper_coupling=taper_coupling, + request.wall, + model=request.model, + loss=request.loss, + lattice=request.lattice, + taper_coupling=request.taper_coupling, ) thg, plasma = ( - (not envelope) if thg is None else thg, - (not envelope) if plasma is None else plasma, + (not request.envelope) if thg is None else thg, + (not request.envelope) if plasma is None else plasma, ) active_plasma = ( any(value is not False for value in plasma.values()) @@ -472,68 +485,77 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface else plasma is not False ) _refuse_plasma_settings( - plasma_integration, envelope=envelope, active=active_plasma, thg=thg + request.plasma_integration, + envelope=request.envelope, + active=active_plasma, + thg=thg, ) working = _capillary_grid( - grid, - length, - wavelength, - wavelength_limits, - trange, - envelope=envelope, + request.grid, + request.length, + request.wavelength, + request.wavelength_limits, + request.trange, + envelope=request.envelope, thg=thg, ) materials = MaterialSelection() if materials is None else materials - _check_selection_carrier(materials, working.reference_wavelength, wavelength) + _check_selection_carrier( + materials, working.reference_wavelength, request.wavelength + ) bore = ( - _flow_radius(modes, pressure) - if waveguide - else as_profile(cast("float | ZProfile", radius)) + _flow_radius(request.modes, request.pressure) + if request.waveguide + else as_profile(cast("float | ZProfile", request.radius)) ) # The fill's own models warn about the conditions they are evaluated at, # which the gas keyword chose. with _warnings_about("gas", _MATERIAL_WARNINGS): mixture = capillary_fill( - gas, - pressure, - length=length, - temperature=temperature, - floor=pressure_floor, - susceptibility=susceptibility, - preionisation=preionisation, + request.gas, + request.pressure, + length=request.length, + temperature=request.temperature, + floor=request.pressure_floor, + susceptibility=request.susceptibility, + preionisation=request.preionisation, radius=bore, - vacuum_end=vacuum_end, + vacuum_end=request.vacuum_end, selection=materials, ) _warn_boreless_gradient(mixture, bore) - if waveguide: - if isinstance(modes, int): + if request.waveguide: + if isinstance(request.modes, int): raise DriverError( "from_waveguide requires supplied gas-filled modes", parameter="modes", ) - mode_set = _given_gas_modes(modes, mixture) + mode_set = _given_gas_modes(request.modes, mixture) else: mode_set = _capillary_modes( - modes, - cast("float | ZProfile", radius), + request.modes, + cast("float | ZProfile", request.radius), mixture, - length=length, - lattice=lattice, - model=model, - loss=loss, - cladding=_default_cladding(cladding) - if isinstance(modes, int) - else cladding, + length=request.length, + lattice=request.lattice, + model=request.model, + loss=request.loss, + cladding=_default_cladding(request.cladding) + if isinstance(request.modes, int) + else request.cladding, ) rule = ( - _no_quadrature(mode_set, n_r, n_theta, radial_rule=radial_rule) - if mode_average - else _capillary_quadrature(mode_set, n_r, n_theta, radial_rule=radial_rule) + _no_quadrature( + mode_set, request.n_r, request.n_theta, radial_rule=request.radial_rule + ) + if request.mode_average + else _capillary_quadrature( + mode_set, request.n_r, request.n_theta, radial_rule=request.radial_rule + ) ) profiles = mixture_profiles(mixture) - if waveguide: + if request.waveguide: for mode in mode_set: paths: Mapping[str, ZProfile] = getattr(mode, "parameter_paths", {}) profiles.update( @@ -545,52 +567,61 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface } ) else: - profiles["core_radius"] = as_profile(cast("float | ZProfile", radius)) + profiles["core_radius"] = as_profile(cast("float | ZProfile", request.radius)) if raman is None: # The default follows the Kerr switch: a run without the electronic # response is a deliberately reduced model, and a field-grid Raman term # cannot stand without a Kerr term beside it anyway. - raman = kerr and _default_raman( + raman = request.kerr and _default_raman( mixture, provisional=materials.raman == "provisional" ) if atomic is not None: responses = [atomic.response] elif ( - isinstance(gas, (Gas, str)) - and not is_air(gas) + isinstance(request.gas, (Gas, str)) + and not is_air(request.gas) and isinstance(raman, bool) and not isinstance(plasma, Mapping) - and not isinstance(preionisation, Mapping) + and not isinstance(request.preionisation, Mapping) ): # The Raman response is optmat's, built from the fill (SEP 3); the other # responses keep this path. with _warnings_about("gas", _MATERIAL_WARNINGS): responses = _capillary_responses( - gas, + request.gas, mixture.components[0].species, - kerr=kerr, + kerr=request.kerr, plasma=plasma, - preionisation=0.0 if preionisation is None else preionisation, - integration=plasma_integration, - wavelength=wavelength, - envelope=envelope, + preionisation=0.0 + if request.preionisation is None + else request.preionisation, + integration=request.plasma_integration, + wavelength=request.wavelength, + envelope=request.envelope, # The single gas's electronic response is optmat's, at the # carrier or dispersive, as the selection's electronic policy # says (SEP 6). electronic=electronic_response( mixture.components[0].species, selection=materials, - wavelength=wavelength, + wavelength=request.wavelength, thg=thg, ) - if kerr + if request.kerr else None, selection=materials, ) # optmat's Raman response is the raman keyword's choice. with _about("raman"), _warnings_about("raman", _MATERIAL_WARNINGS): responses += ( - _optmat_raman(gas, mixture, materials, thg, wavelength, length) + _optmat_raman( + request.gas, + mixture, + materials, + thg, + request.wavelength, + request.length, + ) if raman else [] ) @@ -599,39 +630,39 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface responses = list( responses_for( mixture, - kerr=kerr, + kerr=request.kerr, thg=thg, plasma=plasma, raman=raman, - wavelength=wavelength, - envelope=envelope, - length=length, + wavelength=request.wavelength, + envelope=request.envelope, + length=request.length, selection=materials, - plasma_integration=plasma_integration, + plasma_integration=request.plasma_integration, ) ) with _about(("preionisation", "plasma")): _refuse_idle_preionisation(mixture, responses) pulse = _shaped_pulse( - shape, - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, + request.shape, + wavelength=request.wavelength, + fwhm=request.fwhm, + width=request.width, + energy=request.energy, + peak_power=request.peak_power, + phases=request.phases, + pulses=request.pulses, ) - field0, launched = _hollow_input(pulse, launch, working, mode_set.modes) + field0, launched = _hollow_input(pulse, request.launch, working, mode_set.modes) ionising = any( isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) for one in responses ) - boundary, scheme = _boundary(absorber, absorber_scheme, working) + boundary, scheme = _boundary(request.absorber, request.absorber_scheme, working) # A hollow guide's default is the grid's collar, so there is always one. _warn_capillary_walkoff(mode_set.modes, working, cast("Absorber", boundary)) responses, field_range, reference = _ionisation_inputs( responses, - ionisation_field_range, + request.ionisation_field_range, _InputPeak(field0, mode_set, working, rule) if ionising else None, ) row = ( @@ -639,33 +670,33 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface if any(isinstance(mode, SlabMode) for mode in mode_set.modes) else "capillary tdse" if atomic is not None - else route + else request.route ) - controller, default = _keyword_controller(row, rtol, atol) + controller, default = _keyword_controller(row, request.rtol, request.atol) settings = SimulationSettings( controller=controller, _default_controller=default, _route=row, - save_planes=_save_planes(length, save_planes, n_save), + save_planes=_save_planes(request.length, request.save_planes, n_save), # Relative defaults, so that the first step and the cap scale with the # capillary. - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, + dz_max=request.length / 2 if request.dz_max is None else request.dz_max, + dz_init=request.length * 1e-4 if request.dz_init is None else request.dz_init, + auto_init=request.dz_init is None, + schedule=request.schedule, + integrator=request.integrator, + interpolate_planes=request.interpolate_planes, step_planes=step_planes, density=None, absorber=boundary, absorber_scheme=scheme, - boundary_check=boundary_check, + boundary_check=request.boundary_check, quadrature=rule, components=None if rule is None - else _hollow_components(supplied=not isinstance(modes, int)), + else _hollow_components(supplied=not isinstance(request.modes, int)), ionisation_field_range=field_range, - node_budget=node_budget, + node_budget=request.node_budget, stats=( _with_integration_statistics( _hollow_stats( @@ -674,24 +705,24 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface profiles=profiles, rule=rule, ) - if waveguide + if request.waveguide else _capillary_stats( with_plasma=ionising, mixture=mixture, averaged=rule is None ), responses, ) - if stats is None - else stats + if request.stats is None + else request.stats ) - if atomic is None or stats is not None + if atomic is None or request.stats is not None else _tdse_stats( _capillary_stats(with_plasma=False, mixture=mixture, averaged=rule is None) ), - polarisation_taper=polarisation_taper, + polarisation_taper=request.polarisation_taper, backend=backend, tdse_execution=None if atomic is None else atomic.execution, ) - noise_model, resolution = resolve_noise(row, noise, seed) + noise_model, resolution = resolve_noise(row, request.noise, request.seed) draw = functools.partial(_shot_noise, noise_model, working, n_modes=len(mode_set)) simulation = cls( working, @@ -699,11 +730,11 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface responses, field0, settings=settings, - noise=draw(seed), + noise=draw(request.seed), noise_resolution=resolution, profiles=profiles, - wall=wall, - taper_coupling=taper_coupling, + wall=request.wall, + taper_coupling=request.taper_coupling, materials=materials, configuration={ "medium": mixture, @@ -711,15 +742,15 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface { "waveguide": { "modes": [ModeRecord.from_mode(mode) for mode in mode_set], - "length": length, + "length": request.length, "temperature": mixture.temperature, } } - if waveguide + if request.waveguide else { "capillary": { - "radius": radius, - "length": length, + "radius": request.radius, + "length": request.length, "temperature": mixture.temperature, } } @@ -735,20 +766,20 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface None if atomic is not None else ResponseSelection( - kerr, + request.kerr, thg, plasma, raman, - wavelength, - envelope, - length, + request.wavelength, + request.envelope, + request.length, selection=materials, reference_field=reference, field_range=field_range, - plasma_integration=plasma_integration, + plasma_integration=request.plasma_integration, ) ) - return _redrawable(simulation, draw, seed) + return _redrawable(simulation, draw, request.seed) def _refuse_plasma_settings( diff --git a/src/solprop/driver/routes/keywords.py b/src/solprop/driver/routes/keywords.py index af0aa379..ce85403c 100644 --- a/src/solprop/driver/routes/keywords.py +++ b/src/solprop/driver/routes/keywords.py @@ -9,9 +9,10 @@ """ import contextlib +import dataclasses import warnings from collections.abc import Iterator, Mapping -from typing import Final, cast +from typing import Any, Final, cast from solprop.exceptions import ( MaterialRangeWarning, @@ -121,3 +122,16 @@ def _unset(**keywords: object) -> ParameterPath: - ``**keywords``: the keywords a refusal needs, by name; at least one is ``None``. """ return _path(*(name for name, value in keywords.items() if value is None)) + + +def keyword_request[R]( + kind: type[R], scope: Mapping[str, object], **given: object +) -> R: + """Build a route's request from its constructor's locals. + + Each field of `kind` the constructor's `scope` holds is taken from it, then + `given` overrides or adds fields; a field in neither keeps its default. + """ + # `kind` is a dataclass; the type variable cannot say so. + names = {field.name for field in dataclasses.fields(cast("Any", kind))} + return kind(**{name: scope[name] for name in names if name in scope} | given) diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 6b901007..5b773e81 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -90,17 +90,23 @@ _start, _validate_resume, ) -from solprop.driver.routes.general import _from_general +from solprop.driver.routes.general import ( + GAS_KEYWORD_DEFAULTS, + _from_general, + _GeneralRequest, +) from solprop.driver.routes.gnlse import _gnlse_fibre, _gnlse_grid from solprop.driver.routes.hollow import ( _flow_radius, _from_hollow, + _HollowRequest, _population_label, _rebind_mode, _refuse_idle_preionisation, _warn_boreless_gradient, _warn_sampled_peak, ) +from solprop.driver.routes.keywords import keyword_request from solprop.driver.routes.materials import ( _check_electronic_policy, _check_selection_carrier, @@ -3079,68 +3085,11 @@ def from_capillary( """ return _from_hollow( cls, - radius=radius, - length=length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - modes=modes, - mode_average=mode_average, - lattice=lattice, - wall=wall, - taper_coupling=taper_coupling, - model=model, - loss=loss, - cladding=cladding, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - materials=MaterialSelection() if materials is None else materials, - tdse=tdse, - tdse_execution=tdse_execution, + keyword_request( + _HollowRequest, + locals(), + materials=MaterialSelection() if materials is None else materials, + ), ) @classmethod @@ -3577,69 +3526,16 @@ def from_waveguide( setting is invalid. """ if provider is not None: + # The gas-filled keywords are carried to be refused by name if set. + scope = locals() return _from_general( cls, - provider, - modes, - length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - channel=channel, - incident=incident, - coupling=coupling, - kerr_form=kerr_form, - observable_rtol=observable_rtol, - observable_atol=observable_atol, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - mode_average=mode_average, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - taper_coupling=taper_coupling, - materials=MaterialSelection() if materials is None else materials, + keyword_request( + _GeneralRequest, + scope, + refused={name: scope[name] for name in GAS_KEYWORD_DEFAULTS}, + materials=MaterialSelection() if materials is None else materials, + ), ) for name, value in ( ("channel", channel), @@ -3665,63 +3561,14 @@ def from_waveguide( raise DriverError(msg) return _from_hollow( cls, - radius=None, - modes=modes, - mode_average=mode_average, - waveguide=True, - length=length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - route="waveguide", - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - taper_coupling=taper_coupling, - materials=MaterialSelection() if materials is None else materials, + keyword_request( + _HollowRequest, + locals(), + radius=None, + waveguide=True, + route="waveguide", + materials=MaterialSelection() if materials is None else materials, + ), ) diff --git a/tests/driver/test_general_modal.py b/tests/driver/test_general_modal.py index e0930f4d..025f1ee7 100644 --- a/tests/driver/test_general_modal.py +++ b/tests/driver/test_general_modal.py @@ -71,7 +71,7 @@ from solprop.driver.routes.general import ( GAS_KEYWORD_DEFAULTS, _core_index, - _from_general, + _GeneralRequest, _in_channel, _refuse_gas_keywords, ) @@ -243,15 +243,15 @@ def test_every_gas_keyword_is_refused_by_name_beside_a_provider(guide, grid, key def test_from_waveguide_refuses_or_names_every_keyword_it_takes(): """Lock the signature to the refusal table, so a new keyword cannot be ignored. - ``_from_general`` takes the keywords it carries by name and collects the rest - in ``**refused``; every collected one must be in the table, or it would be + ``_GeneralRequest`` takes the keywords it carries by name and collects the rest + in ``refused``; every collected one must be in the table, or it would be swallowed silently -- accepted by the signature, never read, and never refused. The defaults must agree too: the table's entry is the value that means "not given", and a table entry that is not the signature's default would refuse a call that passed nothing. """ taken = set(inspect.signature(Simulation.from_waveguide).parameters) - named = set(inspect.signature(_from_general).parameters) - {"cls", "refused"} + named = {field.name for field in dataclasses.fields(_GeneralRequest)} - {"refused"} assert set(GAS_KEYWORDS) == set(GAS_KEYWORD_DEFAULTS) assert taken == named | set(GAS_KEYWORD_DEFAULTS) signature = inspect.signature(Simulation.from_waveguide).parameters diff --git a/tests/driver/test_waveguide.py b/tests/driver/test_waveguide.py index 3b5cab48..04e325cf 100644 --- a/tests/driver/test_waveguide.py +++ b/tests/driver/test_waveguide.py @@ -15,7 +15,7 @@ from march_bounds import FIXED_ORDER, STATISTIC_FACTOR, assert_matched, simulation_bound from solprop.driver import Simulation, prop_waveguide from solprop.driver.defaults import WAVEGUIDE_RTOL -from solprop.driver.routes.hollow import _from_hollow +from solprop.driver.routes.hollow import _from_hollow, _HollowRequest from solprop.exceptions import ( DriverError, MaterialRangeWarning, @@ -345,11 +345,13 @@ def test_waveguide_mixture_and_geometry_paths_are_rebound(): with pytest.raises(DriverError, match="requires supplied"): _from_hollow( Simulation, - None, - 0.001, - "Ar", - 5.0, - waveguide=True, - rtol=WAVEGUIDE_RTOL, - **{"noise": None, **settings()}, + _HollowRequest( + radius=None, + length=0.001, + gas="Ar", + pressure=5.0, + waveguide=True, + rtol=WAVEGUIDE_RTOL, + **{"noise": None, **settings()}, + ), ) diff --git a/tests/processing/test_modal_metrics.py b/tests/processing/test_modal_metrics.py index 39ef07ac..cf8cd3ab 100644 --- a/tests/processing/test_modal_metrics.py +++ b/tests/processing/test_modal_metrics.py @@ -113,7 +113,7 @@ def pair_guide(grid): def route_for(guide, grid): """Return the route a run of these tests builds, rebuilt from the guide. - `Simulation._from_general` passes the **last save plane** as `z_max`, so a + `_from_general` passes the **last save plane** as `z_max`, so a caller rebuilding the reference for a reconstruction passes the same: the tables' `z_span` is part of their identity and a different one is refused by name, which is the point of the check. From 8bb825108a9bb852720291cd1c6de0d9a86090d4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:00:37 +0000 Subject: [PATCH 13/42] Document keyword_request as public API of driver.routes.keywords Co-Authored-By: Claude Opus 5.5 --- src/solprop/driver/routes/keywords.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/solprop/driver/routes/keywords.py b/src/solprop/driver/routes/keywords.py index ce85403c..adce6f83 100644 --- a/src/solprop/driver/routes/keywords.py +++ b/src/solprop/driver/routes/keywords.py @@ -22,7 +22,7 @@ SolpropWarning, ) -__all__ = [] +__all__ = ["keyword_request"] @contextlib.contextmanager @@ -129,8 +129,21 @@ def keyword_request[R]( ) -> R: """Build a route's request from its constructor's locals. - Each field of `kind` the constructor's `scope` holds is taken from it, then - `given` overrides or adds fields; a field in neither keeps its default. + Parameters + ---------- + kind : type + The request, a frozen dataclass whose fields are keywords of the route. + scope : collections.abc.Mapping + The public constructor's ``locals()``: each field of `kind` it holds is taken + from it, and anything else in it is ignored. + **given : object + Fields set otherwise than the constructor took them, overriding `scope`. A + field in neither keeps its default. + + Returns + ------- + object + The request, an instance of `kind`. """ # `kind` is a dataclass; the type variable cannot say so. names = {field.name for field in dataclasses.fields(cast("Any", kind))} From 0ccd007aba2faed92dbc6d8eb442f9a807f3b28d Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:01:11 +0000 Subject: [PATCH 14/42] Build both Slurm batch scripts with one function in _process The scan executor's array script and the front end's run script share batch_script (quoted directives, the site's lines, the BLAS idle defaults) and one THREAD_VARIABLES table; each keeps its own thread policy. A scan's script now also leaves OpenBLAS threads idle. Co-Authored-By: Claude Opus 5.5 --- changelog.d/slurm-scan-blas-idle.changed.md | 4 ++ docs/guide/performance/tuning.md | 4 +- src/solprop/_process.py | 41 +++++++++++++++ src/solprop/driver/executors/pool.py | 12 +---- src/solprop/driver/executors/slurm.py | 18 +++---- src/solprop/service/launch.py | 50 +++++++------------ .../test_slurm_script_snapshot_chunk_.sh | 1 + .../test_slurm_script_snapshot_queue_.sh | 1 + ...e_instance_gpu_snapshot_pins_one_worker.sh | 1 + 9 files changed, 77 insertions(+), 55 deletions(-) create mode 100644 changelog.d/slurm-scan-blas-idle.changed.md diff --git a/changelog.d/slurm-scan-blas-idle.changed.md b/changelog.d/slurm-scan-blas-idle.changed.md new file mode 100644 index 00000000..2998e175 --- /dev/null +++ b/changelog.d/slurm-scan-blas-idle.changed.md @@ -0,0 +1,4 @@ +The batch script of a Slurm scan (`solprop.driver.executors.Slurm`) now sets +`OPENBLAS_THREAD_TIMEOUT=20` unless the job's environment sets it, as the front end's +Slurm runs already did, so the scan controller's BLAS threads sleep instead of spinning +between calls. diff --git a/docs/guide/performance/tuning.md b/docs/guide/performance/tuning.md index df86d6da..96792f4b 100644 --- a/docs/guide/performance/tuning.md +++ b/docs/guide/performance/tuning.md @@ -397,8 +397,8 @@ solprop runs a line, and those threads spin for about 0.1 s before they sleep. O time, against 0.75 in 0.67 with `OPENBLAS_THREAD_TIMEOUT=20`, which makes idle threads sleep after about 0.4 ms of spinning and changes no width. A Python process solprop starts itself takes `OPENBLAS_THREAD_TIMEOUT=20` unless the variable is already set: a -run started from the window (directly or as a Slurm job), the window's kernel, and -`Pool` workers. Your own script loads NumPy first, so set it yourself if CPU time on a +run started from the window (directly or as a Slurm job), the window's kernel, +`Pool` workers and a `Slurm` scan's controllers. Your own script loads NumPy first, so set it yourself if CPU time on a shared machine matters, before Python starts: ```sh diff --git a/src/solprop/_process.py b/src/solprop/_process.py index e64c42ce..ea6d68f8 100644 --- a/src/solprop/_process.py +++ b/src/solprop/_process.py @@ -24,6 +24,7 @@ import itertools import os import platform +import shlex import sys import threading import warnings @@ -53,6 +54,16 @@ fast as at the default, and 11 % slower at ``2**4``. Only a process started fresh can take it: OpenBLAS reads it once, when it loads.""" +THREAD_VARIABLES = ( + "SOLPROP_THREADS", + "OMP_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "MKL_NUM_THREADS", + "VECLIB_MAXIMUM_THREADS", +) +"""The variables that size a process's thread pools: solprop's budget, then the OpenMP +and BLAS widths a scan's worker processes are started with.""" + _BLAS_LOCK = threading.RLock() _BLAS_USERS: set[object] = set() _BLAS_LIMITS = contextlib.ExitStack() @@ -69,6 +80,36 @@ def child_environment(base: Mapping[str, str]) -> dict[str, str]: return {**BLAS_IDLE_DEFAULTS, **base} +def batch_script( + directives: Mapping[str, str | None], + body: Sequence[str], + *, + site: Sequence[str] = (), +) -> str: + """Return a Slurm batch script whose Python is a process solprop starts. + + The ``#SBATCH`` directives come first, each value quoted for the shell and one + without a value left out, then the `site`'s own lines (further directives, module + loads), then :data:`BLAS_IDLE_DEFAULTS` where the job's environment sets none of + them (solprop issue #383), then `body`. + """ + lines = [ + "#!/usr/bin/env bash", + *( + f"#SBATCH --{key}={shlex.quote(value)}" + for key, value in directives.items() + if value + ), + *site, + *( + f"export {name}=${{{name}:-{value}}}" + for name, value in BLAS_IDLE_DEFAULTS.items() + ), + *body, + ] + return "\n".join(lines) + "\n" + + _ENVIRONMENT_LOCK = threading.Lock() diff --git a/src/solprop/driver/executors/pool.py b/src/solprop/driver/executors/pool.py index 394c7919..66f05ba3 100644 --- a/src/solprop/driver/executors/pool.py +++ b/src/solprop/driver/executors/pool.py @@ -22,6 +22,7 @@ from solprop._process import ( LOAD_TIME_BLAS_VARIABLES, + THREAD_VARIABLES, allowed_cpus, sizes_blas_after_load, spawn_environment, @@ -41,15 +42,6 @@ else: type _Pipe = Connection -_THREAD_VARIABLES = ( - "SOLPROP_THREADS", - "OMP_NUM_THREADS", - "OPENBLAS_NUM_THREADS", - "MKL_NUM_THREADS", - "VECLIB_MAXIMUM_THREADS", -) - - # The worker pipe's messages. Controller to worker: ``("run", point)`` or # ``("probe", point)``, the ``STOP`` string while a point runs, and ``None`` to exit. # Worker to controller: ``(ok, value)`` once per command, and while a watched point runs @@ -538,7 +530,7 @@ def _spawn( ) # Workers sleep between regions unless the user chose, or a calibration's row for # the workers' allocation did (solprop SEP 46); they never calibrate themselves. - environment = dict.fromkeys(_THREAD_VARIABLES, str(threads)) | { + environment = dict.fromkeys(THREAD_VARIABLES, str(threads)) | { "SOLPROP_NATIVE_WAIT_REQUEST": "passive", "SOLPROP_CALIBRATION_ROLE": "worker", } diff --git a/src/solprop/driver/executors/slurm.py b/src/solprop/driver/executors/slurm.py index 99f62ea2..3367554c 100644 --- a/src/solprop/driver/executors/slurm.py +++ b/src/solprop/driver/executors/slurm.py @@ -17,9 +17,10 @@ from pathlib import Path from typing import Literal +from solprop._process import THREAD_VARIABLES, batch_script from solprop.driver.executors._queue_state import atomic_json from solprop.driver.executors._request import ScanRequest -from solprop.driver.executors.pool import _THREAD_VARIABLES, Pool +from solprop.driver.executors.pool import Pool from solprop.driver.executors.queue import Queue from solprop.exceptions import DriverError @@ -184,17 +185,10 @@ def render(self) -> str: "cpus-per-task": str(self.cpus_per_task), "mem": self.mem, "time": self.time, + "partition": self.partition, + "gres": self.gres, } - if self.partition is not None: - directives["partition"] = self.partition - if self.gres is not None: - directives["gres"] = self.gres lines = [ - "#!/usr/bin/env bash", - *[ - f"#SBATCH --{key}={shlex.quote(value)}" - for key, value in directives.items() - ], "set -euo pipefail", f"cd -- {shlex.quote(str(directory))}", ': "${SLURM_ARRAY_TASK_ID:?set the Slurm array task ID}"', @@ -237,7 +231,7 @@ def render(self) -> str: lines.append(' export CUDA_VISIBLE_DEVICES="${devices[$instance]}"') # Each instance's controller keeps its whole share: SOLPROP_THREADS is the # budget its Pool workers are each spawned with, not the controller's own. - for variable in _THREAD_VARIABLES: + for variable in THREAD_VARIABLES: if variable != "SOLPROP_THREADS": lines.append(f" export {variable}={self.threads}") lines.append(" export SOLPROP_NATIVE_WAIT_REQUEST=passive") @@ -275,7 +269,7 @@ def render(self) -> str: "done", 'exit "$status"', ] - return "\n".join(lines) + "\n" + return batch_script(directives, lines) def submit(self, destination: Path | str | None = None) -> str: """Write the script and submit it with sbatch. diff --git a/src/solprop/service/launch.py b/src/solprop/service/launch.py index 771818cf..af48af9b 100644 --- a/src/solprop/service/launch.py +++ b/src/solprop/service/launch.py @@ -29,7 +29,7 @@ from pathlib import Path from typing import Final -from solprop._process import BLAS_IDLE_DEFAULTS, child_environment +from solprop._process import batch_script, child_environment from solprop.service.messages import ServiceError from solprop.service.record import process_stamp, set_aside, write_json from solprop.service.worker import SPEC, RunSpec, spec_value @@ -307,39 +307,27 @@ def slurm_script( """ name = re.sub(r"[^A-Za-z0-9_.-]", "_", folder.name)[:40] posix = folder.as_posix() # a cluster's path, whatever this computer writes - lines = [ - "#!/bin/bash", - f"#SBATCH --job-name=solprop-{name}", - f"#SBATCH --output={shlex.quote(posix + '/' + JOB_OUTPUT)}", - f"#SBATCH --chdir={shlex.quote(posix)}", - "#SBATCH --ntasks=1", - f"#SBATCH --cpus-per-task={int(resources.cpus)}", - f"#SBATCH --signal=B:TERM@{WARNING_SECONDS}", + directives = { + "job-name": f"solprop-{name}", + "output": posix + "/" + JOB_OUTPUT, + "chdir": posix, + "ntasks": "1", + "cpus-per-task": str(int(resources.cpus)), + "signal": f"B:TERM@{WARNING_SECONDS}", + "partition": resources.partition, + "time": resources.time, + "mem": resources.memory, + "gres": resources.gres, + } + site = [ + *(f"#SBATCH {option.strip()}" for option in resources.extra if option.strip()), + *(line for line in resources.setup if line.strip()), ] - if resources.partition: - lines.append(f"#SBATCH --partition={resources.partition}") - if resources.time: - lines.append(f"#SBATCH --time={resources.time}") - if resources.memory: - lines.append(f"#SBATCH --mem={resources.memory}") - if resources.gres: - lines.append(f"#SBATCH --gres={resources.gres}") - lines.extend( - f"#SBATCH {option.strip()}" for option in resources.extra if option.strip() - ) - lines.extend(line for line in resources.setup if line.strip()) budget = str(threads) if threads is not None else "${SLURM_CPUS_PER_TASK:-1}" - lines.append(f"export SOLPROP_THREADS={budget}") - # A job is a fresh Python: its BLAS pools spin unless told (solprop issue #383). - lines.extend( - f"export {name}=${{{name}:-{value}}}" - for name, value in BLAS_IDLE_DEFAULTS.items() - ) - command = " ".join( - shlex.quote(part) for part in [python, "-m", "solprop.service.job", posix] + command = shlex.join([python, "-m", "solprop.service.job", posix]) + return batch_script( + directives, [f"export SOLPROP_THREADS={budget}", f"exec {command}"], site=site ) - lines.append(f"exec {command}") - return "\n".join(lines) + "\n" def _run( diff --git a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh index c5ffdc51..692a0147 100644 --- a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh +++ b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh @@ -5,6 +5,7 @@ #SBATCH --time=00:30:00 #SBATCH --partition='cpu queue' #SBATCH --gres=gpu:h200:2 +export OPENBLAS_THREAD_TIMEOUT=${OPENBLAS_THREAD_TIMEOUT:-20} set -euo pipefail cd -- /shared/scan : "${SLURM_ARRAY_TASK_ID:?set the Slurm array task ID}" diff --git a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh index 4eecabc4..9adf30b8 100644 --- a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh +++ b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh @@ -5,6 +5,7 @@ #SBATCH --time=00:30:00 #SBATCH --partition='cpu queue' #SBATCH --gres=gpu:h200:2 +export OPENBLAS_THREAD_TIMEOUT=${OPENBLAS_THREAD_TIMEOUT:-20} set -euo pipefail cd -- /shared/scan : "${SLURM_ARRAY_TASK_ID:?set the Slurm array task ID}" diff --git a/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh b/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh index 3b953805..ebf13cef 100644 --- a/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh +++ b/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh @@ -4,6 +4,7 @@ #SBATCH --mem=4G #SBATCH --time=01:00:00 #SBATCH --gres=gpu:h200:1 +export OPENBLAS_THREAD_TIMEOUT=${OPENBLAS_THREAD_TIMEOUT:-20} set -euo pipefail cd -- /shared/scan : "${SLURM_ARRAY_TASK_ID:?set the Slurm array task ID}" From e39cea3eb4847366b2f8d92de8f0821dc48a4684 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:03:33 +0000 Subject: [PATCH 15/42] Route pressure_zdw and pressure_rdw through solve_parameter Co-Authored-By: Claude Opus 5.5 --- .../pressure-solvers-search.changed.md | 7 + docs/guide/workflows/design-tools.md | 15 +- src/solprop/tools/design.py | 170 +++++++----------- tests/test_design_solvers.py | 36 ++-- tests/test_soliton_content.py | 43 +++-- tests/test_tools.py | 9 +- 6 files changed, 138 insertions(+), 142 deletions(-) create mode 100644 changelog.d/pressure-solvers-search.changed.md diff --git a/changelog.d/pressure-solvers-search.changed.md b/changelog.d/pressure-solvers-search.changed.md new file mode 100644 index 00000000..6be432aa --- /dev/null +++ b/changelog.d/pressure-solvers-search.changed.md @@ -0,0 +1,7 @@ +`solprop.tools.pressure_zdw` and `solprop.tools.pressure_rdw` now run the search of +`solprop.tools.solve_parameter`. `pressure_zdw` samples the pressure range rather than +taking one Brent bracket over it: where the dispersion has one zero in the range it finds +the same root, to within the noise of the differenced dispersion (a few parts in 1e8); a +sign change through a resonance is no longer taken for a zero; and with several zeros it +returns the lowest. When no pressure in the range meets the target, both now say so as +`solve_parameter` does, naming the range of the quantity the range gives. diff --git a/docs/guide/workflows/design-tools.md b/docs/guide/workflows/design-tools.md index 49bb5778..1e3bb4fe 100644 --- a/docs/guide/workflows/design-tools.md +++ b/docs/guide/workflows/design-tools.md @@ -292,12 +292,15 @@ rests on an extrapolated cladding index. ## The pressure that tunes it {py:func}`~solprop.tools.design.pressure_rdw` inverts the calculation: the pressure at which -the dispersive wave is phase matched at a target wavelength, by Brent's method between -$10^{-6}$ bar and `pressure_max`. With `peak_power` the nonlinear coefficient follows the -pressure inside the search. {py:func}`~solprop.tools.design.pressure_zdw` does the same for the -zero-dispersion wavelength. The capillary is built once and re-evaluated at each density -through its parameterised form, rather than rebuilt with its equation of state at every -step, so a search costs milliseconds. +the dispersive wave is phase matched at a target wavelength, searched between +$10^{-6}$ bar and `pressure_max` as {py:func}`~solprop.tools.design.solve_parameter` +searches (below), and refused when there is more than one. With `peak_power` the +nonlinear coefficient follows the pressure inside the search. +{py:func}`~solprop.tools.design.pressure_zdw` does the same for the zero-dispersion +wavelength, and takes the lowest root. The capillary is built once and re-evaluated at +each density through its parameterised form, rather than rebuilt with its equation of +state at every step, so a trial pressure costs about a millisecond and a search a few +tenths of a second. ```{code-block} python >>> from solprop.tools import lambda_rdw, pressure_rdw diff --git a/src/solprop/tools/design.py b/src/solprop/tools/design.py index 6ff8de4b..bd361ec4 100644 --- a/src/solprop/tools/design.py +++ b/src/solprop/tools/design.py @@ -78,7 +78,7 @@ _SEARCH_SAMPLES = 129 """Linear samples of a parameter search, and logarithmic ones beside them where the -range is positive: :func:`pressure_rdw`'s grid, which :func:`solve_parameter` shares.""" +range is positive: the grid of :func:`solve_parameter` and the pressure solvers.""" _ATTAINABLE_SAMPLES = 17 @@ -104,35 +104,12 @@ :data:`~solprop.tools.waveguide._POLE_FACTOR`), so the residual's own size decides.""" -def _pressure_root( - residual: Callable[[float], float], pressure_max: float, what: str -) -> float: - """Return the pressure at which a function of pressure vanishes. - - - ``residual``: a function of the pressure in bar. - - ``pressure_max``: upper end of the bracket in bar. - - Returns: the root in bar. - """ +def _pressure_bracket(pressure_max: float) -> tuple[float, float]: + """Return the pressure bracket in bar from the floor to `pressure_max`.""" top = positive(pressure_max, "pressure_max", ToolsError) if top <= _PRESSURE_FLOOR: raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") - with _probing(): - low, high = residual(_PRESSURE_FLOOR), residual(top) - if not (math.isfinite(low) and math.isfinite(high)): - raise ToolsError( - f"the residual for {what} is not finite at the ends of the pressure " - f"bracket ({low!r}, {high!r}): the index model does not reach that far" - ) - if low * high > 0.0: - raise ToolsError( - f"no pressure between {_PRESSURE_FLOOR} and {top:.4g} bar gives {what}: " - f"the residual is {low:.4g} at the low end and {high:.4g} at the high end" - ) - with _probing(): - root = float(brentq(residual, _PRESSURE_FLOOR, top)) - # Outside the probing context, so the answer's own warnings are raised. - residual(root) - return root + return _PRESSURE_FLOOR, top def _design_capillary( @@ -315,39 +292,6 @@ def refine(value: float) -> float: ) -def _rdw_pressure_root( - residual: Callable[[float], float | None], limits: tuple[float, float] -) -> float: - """Find a unique pressure root without crossing a sampled inadmissible region. - - - ``residual``: mismatch in 1/m, or None only where no focusing discrete soliton - exists. - - ``limits``: positive ordered pressure bracket in bar. - - Returns: unique pressure root in bar. - """ - low, high = limits - if not (math.isfinite(low) and math.isfinite(high) and 0 < low < high): - raise ToolsError("pressure_limits must be finite, positive and ordered") - roots = _sampled_roots( - residual, limits, what="the RDW pressure residual", narrow="pressure_limits" - ).roots - if not roots: - raise ToolsError( - f"no pressure between {low:g} and {high:g} bar gives an admissible " - "RDW root; narrow pressure_limits if needed" - ) - root, *others = roots - if others: - found = ", ".join(f"{value:.4g}" for value in roots) - raise ToolsError( - f"ambiguous RDW pressure: {len(roots)} roots, at {found} bar; " - "choose one with pressure_limits" - ) - # Re-evaluate the accepted answer outside the probing warning filter. - residual(root) - return root - - def pressure_rdw( radius: float, gas: Gas | str, @@ -439,11 +383,11 @@ def pressure_rdw( shape=shape, nonlinear_phase=nonlinear_phase, ) - top = positive(pressure_max, "pressure_max", ToolsError) - if top <= _PRESSURE_FLOOR: - raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") - limits = (_PRESSURE_FLOOR, top) if pressure_limits is None else pressure_limits - if limits[0] < _PRESSURE_FLOOR or limits[1] > top: + floor, top = _pressure_bracket(pressure_max) + low, high = (floor, top) if pressure_limits is None else pressure_limits + if not (math.isfinite(low) and math.isfinite(high) and 0 < low < high): + raise ToolsError("pressure_limits must be finite, positive and ordered") + if low < floor or high > top: raise ToolsError("pressure_limits must lie within (1e-6, pressure_max) bar") materials = MaterialSelection() if materials is None else materials _check_carrier(materials, wavelength) @@ -458,21 +402,23 @@ def pressure_rdw( materials=materials, ) - def _residual(pressure: float) -> float | None: - """Return the mismatch at the target for one pressure. - - - ``pressure``: pressure in bar. - - Returns: mismatch in 1/m, or None when the constituent does not exist. - """ + def _build(pressure: float) -> Mode | tuple[Mode, float]: + """Return the capillary at a pressure in bar, with its gamma for a Kerr term.""" mode = _capillary_at(base_mode, pressure) - gamma = None - if peak_power is not None: - assert mode.mixture is not None - index = _fill_n2(mode.mixture, wavelength)[0] - gamma = nonlinear_coefficient(mode, index, wavelength) - return _rdw_residual(design, mode, gamma, 0.0) - - return _rdw_pressure_root(_residual, limits) + if peak_power is None: + return mode + assert mode.mixture is not None + index = _fill_n2(mode.mixture, wavelength)[0] + return mode, nonlinear_coefficient(mode, index, wavelength) + + roots = _solve(_build, (low, high), design, narrow="pressure_limits") + if len(roots) > 1: + found = ", ".join(f"{value:.4g}" for value in roots) + raise ToolsError( + f"ambiguous RDW pressure: {len(roots)} roots, at {found} bar; " + "choose one with pressure_limits" + ) + return roots.pick() def pressure_zdw( @@ -489,6 +435,9 @@ def pressure_zdw( ) -> float: """Return the pressure that puts the zero-dispersion point at a wavelength. + The capillary form of :func:`solve_parameter` over pressure, with a + :class:`ZDW` target: the lowest root, should there be several. + Parameters ---------- radius : float @@ -521,7 +470,8 @@ def pressure_zdw( Raises ------ ToolsError - If no pressure in the bracket does. + If no pressure in the bracket does, naming the zero-dispersion wavelengths + that the bracket gives. Examples -------- @@ -529,6 +479,8 @@ def pressure_zdw( >>> f"{pressure_zdw(125e-6, 'Ar', 500e-9, cladding=1.45):.3f}" '0.066' """ + bracket = _pressure_bracket(pressure_max) + target = ZDW(zdw_wavelength) materials = MaterialSelection() if materials is None else materials base_mode = _design_capillary( radius, @@ -540,24 +492,13 @@ def pressure_zdw( lattice=None, materials=materials, ) - omega = np.array([_omega(zdw_wavelength)]) - - def _residual(pressure: float) -> float: - """Return the dispersion at the wavelength for one pressure. - - - ``pressure``: pressure in bar. - - Returns: ``beta_2`` in s^2/m, scaled to order one. - """ - mode = _capillary_at(base_mode, pressure) - # Scaled so that brentq's absolute tolerance on the residual is meaningless - # and its tolerance on the pressure decides, as intended. - return float(base.beta2(mode, omega)[0]) * 1e30 - - return _pressure_root( - _residual, - pressure_max, - f"a zero-dispersion wavelength of {zdw_wavelength * 1e9:.4g} nm", + roots = _solve( + lambda pressure: _capillary_at(base_mode, pressure), + bracket, + target, + narrow="pressure_max", ) + return roots.pick() @dataclass(frozen=True) @@ -1113,15 +1054,14 @@ def solve_parameter( """Return every value of a guide or fill parameter that meets a design target. The general form of :func:`pressure_zdw`, :func:`pressure_rdw` and - :func:`radius_zdw`, for any mode and any parameter: a radius, a pressure, a core - size or a wall thickness. The target's residual is sampled over `search` on 129 - linear values and, where ``low > 0``, 129 logarithmic ones (as - :func:`pressure_rdw` samples pressure); each sign change between two samples is - refined with Brent's method to a relative tolerance of 2e-12; and a refined - change is kept as a root only where the residual has fallen to a thousandth of - its value at the bracket's ends, which discards a change through a pole (a wall - resonance, where it diverges instead). Roots closer together than one sample - are not separated: narrow `search` to resolve them. + :func:`radius_zdw`, which call it, for any mode and any parameter: a radius, a + pressure, a core size or a wall thickness. The target's residual is sampled over + `search` on 129 linear values and, where ``low > 0``, 129 logarithmic ones; each + sign change between two samples is refined with Brent's method to a relative + tolerance of 2e-12; and a refined change is kept as a root only where the residual + has fallen to a thousandth of its value at the bracket's ends, which discards a + change through a pole (a wall resonance, where it diverges instead). Roots closer + together than one sample are not separated: narrow `search` to resolve them. Parameters ---------- @@ -1173,6 +1113,22 @@ def solve_parameter( raise ToolsError(f"search must be finite and ordered, got {search!r}") if not math.isfinite(z): raise ToolsError(f"z must be finite, got {z!r}") + return _solve(build, (low, high), target, z=z, narrow="search") + + +def _solve( + build: DesignBuild, + search: tuple[float, float], + target: DesignTarget, + *, + z: float = 0.0, + narrow: str, +) -> DesignRoots: + """Return every root of a target over a finite ordered range: `solve_parameter`. + + - ``narrow``: the keyword a message tells the caller to narrow. + """ + low, high = search builds = 0 def _residual(value: float) -> float | None: @@ -1186,7 +1142,7 @@ def _residual(value: float) -> float | None: return _target_residual(target, mode, gamma, z) sampled = _sampled_roots( - _residual, (low, high), what="the target's residual", narrow="search" + _residual, (low, high), what="the target's residual", narrow=narrow ) # Outside the probing context, so a root beyond an index model's range warns. for root in sampled.roots: diff --git a/tests/test_design_solvers.py b/tests/test_design_solvers.py index 950e381b..d272c6c9 100644 --- a/tests/test_design_solvers.py +++ b/tests/test_design_solvers.py @@ -9,6 +9,7 @@ import numpy as np import pytest +from scipy.optimize import brentq from solprop import tools from solprop.constants import C @@ -93,20 +94,35 @@ def test_the_rdw_target_reproduces_pressure_rdw(pulse): assert 256 < roots.builds < 300 -def test_the_zdw_target_reproduces_pressure_zdw_where_it_has_one_root(): - expected = tools.pressure_zdw(125e-6, "Ar", 500e-9, cladding=GLASS) - fill = capillary("Ar") +@pytest.mark.parametrize( + ("gas", "radius", "wavelength"), + [("Ar", 125e-6, 500e-9), ("Ar", 125e-6, PUMP), ("He", 100e-6, 500e-9)], +) +def test_pressure_zdw_chooses_the_root_one_brent_bracket_over_the_range_does( + gas, radius, wavelength +): + # The former pressure_zdw: one Brent bracket from the floor to pressure_max, with + # brentq's default xtol of 2e-12 bar. Where the dispersion has one zero in the + # range, the sampled search of solve_parameter must choose the same one. + fill = capillary(gas, radius) + omega = np.array([2.0 * math.pi * C / wavelength]) + with tools_waveguide._probing(): + expected = brentq( + lambda p: base.beta2(tools_design._capillary_at(fill, p), omega)[0] * 1e30, + 1e-6, + 100.0, + ) + found = tools.pressure_zdw(radius, gas, wavelength, cladding=GLASS) + # Both stop inside the band in which beta2, a Richardson difference good to 5e-9 + # (modes.base), is noise, which its slope in pressure carries to the root as a + # few parts in 1e8. + assert found == pytest.approx(expected, rel=1e-6) roots = solve_parameter( lambda pressure: tools_design._capillary_at(fill, pressure), (1e-6, 100.0), - ZDW(500e-9), + ZDW(wavelength), ) - assert len(roots) == 1 - # pressure_zdw brackets once and stops at xtol = 2e-12 bar, this search refines - # one sampled bracket: both roots sit inside the band in which beta2, a - # Richardson difference good to 5e-9 (modes.base), is noise, which its slope in - # pressure carries to the root as a few parts in 1e8. - assert roots.pick() == pytest.approx(expected, rel=1e-6) + assert roots.roots == (found,) def test_radius_zdw_inverts_the_modes_zero_dispersion_wavelength(): diff --git a/tests/test_soliton_content.py b/tests/test_soliton_content.py index 17d37db7..118f0542 100644 --- a/tests/test_soliton_content.py +++ b/tests/test_soliton_content.py @@ -282,17 +282,32 @@ def test_pressure_search_inverts_constituent_phase_for_both_modes(shape, vincett assert found == pytest.approx(pressure, rel=1e-6) -def test_pressure_search_rejects_ambiguous_or_inadmissible_roots(): +def sampled(residual, limits): + """Search a residual over a pressure range as pressure_rdw does.""" + return tools_design._sampled_roots( + residual, limits, what="the target's residual", narrow="pressure_limits" + ) + + +def test_pressure_search_rejects_ambiguous_or_inadmissible_roots(monkeypatch): + # The sampled search finds both roots; pressure_rdw refuses to choose. + assert sampled(lambda p: (p - 2) * (p - 3), (1, 4)).roots == pytest.approx( + (2.0, 3.0), rel=1e-12 + ) + target = tools.RDW(PUMP, 200e-9) + found = tools.DesignRoots((2.0, 3.0), (1.0, 4.0), target, 0, "") + monkeypatch.setattr(tools_design, "_solve", lambda *args, **kwargs: found) with pytest.raises(ToolsError, match=r"2 roots, at 2, 3 bar"): - tools_design._rdw_pressure_root(lambda p: (p - 2) * (p - 3), (1, 4)) - with pytest.raises(ToolsError, match="no pressure"): - tools_design._rdw_pressure_root( - lambda p: None if 2 < p < 3 else p - 2.5, (1, 4) - ) + tools.pressure_rdw(125e-6, "He", 200e-9, PUMP, cladding=1.45) + monkeypatch.undo() + # A sign change across a region with no soliton is no root. + assert sampled(lambda p: None if 2 < p < 3 else p - 2.5, (1, 4)).roots == () with pytest.raises(ToolsError, match="not finite"): - tools_design._rdw_pressure_root(lambda p: math.nan, (1, 4)) + sampled(lambda p: math.nan, (1, 4)) with pytest.raises(ToolsError, match="ordered"): - tools_design._rdw_pressure_root(lambda p: p, (4, 1)) + tools.pressure_rdw( + 125e-6, "He", 200e-9, PUMP, pressure_limits=(4, 1), cladding=1.45 + ) def test_params_report_constituents_and_normal_dispersion_unavailability(): @@ -332,7 +347,7 @@ def test_pressure_limits_reject_invalid_ranges(limits): def test_pressure_constituent_search_rejects_subthreshold_pulses(): - with pytest.raises(ToolsError, match="no pressure"): + with pytest.raises(ToolsError, match=r"no value between 0\.1 and 0\.2 gives"): tools.pressure_rdw( 125e-6, "He", @@ -380,21 +395,19 @@ def test_unresolved_positive_eigenvalue_is_rejected(): def test_pressure_refinement_refuses_an_unresolved_no_soliton_gap(): root = 2.123456 with pytest.raises(ToolsError, match="crossed a no-soliton region"): - tools_design._rdw_pressure_root( - lambda p: None if abs(p - root) < 1e-5 else p - root, (1.0, 4.0) - ) + sampled(lambda p: None if abs(p - root) < 1e-5 else p - root, (1.0, 4.0)) def test_pressure_search_requires_a_nonempty_bracket(): with pytest.raises(ToolsError, match="pressure_max must exceed"): tools.pressure_rdw(125e-6, "He", 200e-9, PUMP, pressure_max=1e-7) - with pytest.raises(ToolsError, match="no pressure between"): - tools_design._pressure_root(lambda p: 1.0, 100.0, "an unreachable target") + with pytest.raises(ToolsError, match=r"no value between 1e-06 and 0\.001 gives"): + tools.pressure_zdw(125e-6, "He", 400e-9, pressure_max=1e-3, cladding=1.45) @pytest.mark.parametrize( "dispersion, message", - [(math.nan, "dispersion is not finite"), (1e-26, "no pressure")], + [(math.nan, "dispersion is not finite"), (1e-26, "no value between")], ) def test_pressure_search_rejects_invalid_pump_dispersion( monkeypatch, dispersion, message diff --git a/tests/test_tools.py b/tests/test_tools.py index a4a1548a..63b89063 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -672,7 +672,7 @@ def test_the_pressure_with_the_kerr_term_is_higher(): def test_a_pressure_bracket_without_a_root_is_refused(): - with pytest.raises(ToolsError, match="no pressure between"): + with pytest.raises(ToolsError, match=r"no value between 1e-06 and 0\.5 gives"): tools.pressure_rdw(125e-6, "He", 186e-9, PUMP, pressure_max=0.5, cladding=GLASS) with pytest.raises(ToolsError, match="pressure_max must exceed"): tools.pressure_zdw(125e-6, "He", 400e-9, pressure_max=1e-7, cladding=GLASS) @@ -994,9 +994,10 @@ def test_an_antiresonant_fibre_s_resonances_are_skipped_not_raised(): assert abs(beside[1]) < 1e-3 * max(abs(beside[0]), abs(beside[2])) -def test_a_residual_that_is_not_a_number_is_a_tools_error(): - with pytest.raises(ToolsError, match="not finite at the ends"): - tools_design._pressure_root(lambda pressure: math.nan, 10.0, "anything") +def test_a_residual_that_is_not_a_number_is_a_tools_error(monkeypatch): + monkeypatch.setattr(tools_design.base, "beta2", lambda *args: np.array([math.nan])) + with pytest.raises(ToolsError, match="the target's residual is not finite at"): + tools.pressure_zdw(125e-6, "He", 400e-9, pressure_max=10.0, cladding=GLASS) def test_the_tools_build_the_capillary_the_run_interface_builds(): From 04e85e54df8ffc577f8cd62f46fa13cc2f0505e0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:05:12 +0000 Subject: [PATCH 16/42] Build keyword-route settings and defaults in one place keyword_settings builds the settings of the GNLSE, hollow and general routes (controller, step defaults) in place of three SimulationSettings constructions; SimulationSettings.with_boundaries applies a spatial constructor's boundary keywords in place of three _bounded(_watched( _with_boundary(...))) chains. The step-length fractions, the defaults-row choice (route_row) and the described defaults (describe_default) live in driver.defaults, which recipe/metadata.py now reads through one resolver instead of re-implementing them; the test that only kept the two in sync is removed. Co-Authored-By: Claude Opus 5.5 --- src/solprop/driver/defaults.py | 120 +++++++++++ src/solprop/driver/routes/general.py | 18 +- src/solprop/driver/routes/hollow.py | 32 ++- src/solprop/driver/settings.py | 273 +++++++++++++++---------- src/solprop/driver/simulation.py | 58 +++--- src/solprop/recipe/metadata.py | 92 +++------ tests/driver/test_absorber_keywords.py | 24 ++- tests/recipe/test_metadata.py | 47 ----- 8 files changed, 377 insertions(+), 287 deletions(-) diff --git a/src/solprop/driver/defaults.py b/src/solprop/driver/defaults.py index c13c4999..2dce0d8d 100644 --- a/src/solprop/driver/defaults.py +++ b/src/solprop/driver/defaults.py @@ -47,6 +47,7 @@ "SLAB_NOISE", "SPATIAL_ATOL", "SPATIAL_RTOL", + "STEP_FRACTIONS", "TDSE_NOISE", "WAVEGUIDE_ATOL", "WAVEGUIDE_RTOL", @@ -58,8 +59,11 @@ "default_integrator", "default_rtol", "default_scheme", + "default_step", + "describe_default", "resolve_noise", "route_defaults", + "route_row", ] DEFAULT_RTOL = 1e-7 @@ -275,6 +279,20 @@ class RouteDefaults: """ +STEP_FRACTIONS: Mapping[str, tuple[float, str]] = MappingProxyType( + { + "dz_max": (0.5, "half the length"), + "dz_init": (1e-4, "a ten-thousandth of the length"), + } +) +"""The step lengths a keyword route takes when none is given, as fractions of length. + +Relative, so that the first step and the cap scale with the guide; each with its rule in +words. The same on every keyword route (``from_gnlse``, ``from_capillary`` and +``from_waveguide``); the object routes take their steps in their settings. +""" + + def route_defaults(route: str) -> RouteDefaults: """Return a route's row of :data:`ROUTE_DEFAULTS`. @@ -484,3 +502,105 @@ def resolve_noise( warnings.warn(SolpropWarning(msg, parameter="noise"), stacklevel=4) # An unknown model passes through to be refused where it is drawn. return cast("NoiseModel | None", noise), NoiseResolution(noise, noise) + + +def default_step(keyword: Literal["dz_max", "dz_init"], length: float) -> float: + """Return a keyword route's default step length. + + Parameters + ---------- + keyword : {'dz_max', 'dz_init'} + Which step: the cap or the first. + length : float + The guide's length, m. + + Returns + ------- + float + The step, m: :data:`STEP_FRACTIONS` of `length`. + + Examples + -------- + >>> from solprop.driver.defaults import default_step + >>> default_step("dz_max", 0.4), default_step("dz_init", 1.0) + (0.2, 0.0001) + """ + return STEP_FRACTIONS[keyword][0] * length + + +def route_row(route: str, *, tdse: bool = False, slab: bool = False) -> RouteName: + """Return the row of :data:`ROUTE_DEFAULTS` a route's run reads. + + Parameters + ---------- + route : str + The route: a :data:`RouteName` without its variant. + tdse : bool, optional + Whether a capillary run carries the TDSE response. + slab : bool, optional + Whether the mode set is a slab set, which carries no noise. + + Returns + ------- + str + ``"waveguide slab"`` for a slab set, ``"capillary tdse"`` beside a TDSE + response, and `route` otherwise. + + Examples + -------- + >>> from solprop.driver.defaults import route_row + >>> route_row("capillary", tdse=True), route_row("waveguide", slab=True) + ('capillary tdse', 'waveguide slab') + """ + if slab: + return "waveguide slab" + if tdse: + return "capillary tdse" + return cast("RouteName", route) + + +def describe_default( + row: str, + keyword: Literal["rtol", "integrator", "noise", "dz_max", "dz_init"], + length: float | None = None, +) -> tuple[object, str]: + """Return what a route takes for a keyword it is not given, and the rule in words. + + What a front end shows beside an unset keyword (SEP 41): the same table the + constructors resolve from. + + Parameters + ---------- + row : str + A :data:`RouteName` (:func:`route_row`). + keyword : {'rtol', 'integrator', 'noise', 'dz_max', 'dz_init'} + The keyword. + length : float or None, optional + The guide's length, m, which a step default is a fraction of; ``None`` when + it is not known, and the step's value is then ``None``. + + Returns + ------- + tuple + The value (a tolerance, an integrator, a noise model or ``None``, or a step in + m) and its rule. + + Examples + -------- + >>> from solprop.driver.defaults import describe_default + >>> describe_default("capillary", "rtol") + (1e-06, "the capillary route's default (solprop.driver.defaults.ROUTE_DEFAULTS)") + >>> describe_default("gnlse", "dz_max", 0.4) + (0.2, 'half the length') + """ + if keyword == "rtol": + rule = f"the {row} route's default (solprop.driver.defaults.ROUTE_DEFAULTS)" + return default_rtol(row), rule + if keyword == "integrator": + return default_integrator(row), f"the {row} route's default" + if keyword == "noise": + model, resolution = resolve_noise(row, "default", None) + per = "transverse column" if row in ("radial", "free", "planar") else "mode" + return model, resolution.reason or f"one photon per {per} on the input" + fraction, rule = STEP_FRACTIONS[keyword] + return (None if length is None else fraction * length), rule diff --git a/src/solprop/driver/routes/general.py b/src/solprop/driver/routes/general.py index 8556e028..e2239639 100644 --- a/src/solprop/driver/routes/general.py +++ b/src/solprop/driver/routes/general.py @@ -28,10 +28,9 @@ from solprop.driver.routes.materials import _selected_guide from solprop.driver.settings import ( NoiseSetting, - SimulationSettings, _boundary, - _keyword_controller, _save_planes, + keyword_settings, ) from solprop.exceptions import DriverError, TranslationError from solprop.grid import TimeGrid @@ -260,15 +259,14 @@ def _from_general(cls: type["Simulation"], request: _GeneralRequest) -> "Simulat metric=metric_at(route.metric_tables, 0.0), ) boundary, scheme = _boundary(request.absorber, request.absorber_scheme, working) - controller, default = _keyword_controller("waveguide", request.rtol, request.atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route="waveguide", + settings = keyword_settings( + "waveguide", + request.length, + rtol=request.rtol, + atol=request.atol, + dz_max=request.dz_max, + dz_init=request.dz_init, save_planes=planes, - dz_max=request.length / 2 if request.dz_max is None else request.dz_max, - dz_init=request.length * 1e-4 if request.dz_init is None else request.dz_init, - auto_init=request.dz_init is None, schedule=request.schedule, integrator=request.integrator, interpolate_planes=request.interpolate_planes, diff --git a/src/solprop/driver/routes/hollow.py b/src/solprop/driver/routes/hollow.py index 6820c187..055823d2 100644 --- a/src/solprop/driver/routes/hollow.py +++ b/src/solprop/driver/routes/hollow.py @@ -20,7 +20,7 @@ from scipy.special import j0, jn_zeros from solprop.boundaries import Absorber, warn_walkoff -from solprop.driver.defaults import WAVEGUIDE_ATOL, resolve_noise +from solprop.driver.defaults import WAVEGUIDE_ATOL, resolve_noise, route_row from solprop.driver.inputs import ( ModalLaunch, _hollow_input, @@ -46,10 +46,9 @@ from solprop.driver.routes.materials import _check_selection_carrier, _default_cladding from solprop.driver.settings import ( NoiseSetting, - SimulationSettings, _boundary, - _keyword_controller, _save_planes, + keyword_settings, ) from solprop.driver.tdse_run import _entry_overrides, _tdse_entry, _tdse_stats from solprop.exceptions import ( @@ -665,24 +664,19 @@ def _from_hollow( # noqa: PLR0915 - the route's whole build, keyword by keyword request.ionisation_field_range, _InputPeak(field0, mode_set, working, rule) if ionising else None, ) - row = ( - "waveguide slab" - if any(isinstance(mode, SlabMode) for mode in mode_set.modes) - else "capillary tdse" - if atomic is not None - else request.route + row = route_row( + request.route, + tdse=atomic is not None, + slab=any(isinstance(mode, SlabMode) for mode in mode_set.modes), ) - controller, default = _keyword_controller(row, request.rtol, request.atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route=row, + settings = keyword_settings( + row, + request.length, + rtol=request.rtol, + atol=request.atol, + dz_max=request.dz_max, + dz_init=request.dz_init, save_planes=_save_planes(request.length, request.save_planes, n_save), - # Relative defaults, so that the first step and the cap scale with the - # capillary. - dz_max=request.length / 2 if request.dz_max is None else request.dz_max, - dz_init=request.length * 1e-4 if request.dz_init is None else request.dz_init, - auto_init=request.dz_init is None, schedule=request.schedule, integrator=request.integrator, interpolate_planes=request.interpolate_planes, diff --git a/src/solprop/driver/settings.py b/src/solprop/driver/settings.py index 1cf611fa..2ca41a2c 100644 --- a/src/solprop/driver/settings.py +++ b/src/solprop/driver/settings.py @@ -13,7 +13,7 @@ import warnings from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import Literal, SupportsIndex, cast +from typing import Any, Literal, SupportsIndex, cast import numpy as np from jaxtyping import Float @@ -29,6 +29,7 @@ default_integrator, default_rtol, default_scheme, + default_step, ) from solprop.exceptions import BackendError, DriverError, GridError, SolpropWarning from solprop.grid import TimeGrid @@ -57,6 +58,7 @@ "NoiseSetting", "SimulationSettings", "default_collar", + "keyword_settings", ] LOOSE_ADAMS_RTOL = 5e-2 @@ -645,6 +647,114 @@ def defaults_resolution(self) -> DefaultsResolution: ) return DefaultsResolution(self._route, defaulted) + def with_boundaries( + self, + grid: TimeGrid, + *, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + ) -> "SimulationSettings": + """Return these settings with a spatial constructor's boundary keywords applied. + + Each keyword may be given here or in the settings, not both (SEP 21 section 5, + SEP 40); its default leaves the settings' own. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + The run's time grid, which sizes a collar switched off. + absorber : solprop.boundaries.Absorber, False or None, optional + The temporal and spectral collar. ``False`` zeroes the rates of the + settings' absorber and keeps its lengths, or without one builds the hard + band mask alone. + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + How the temporal collar is applied. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges; ``None`` watches nothing. + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional + The transverse boundary. + + Returns + ------- + SimulationSettings + The settings, replaced where a keyword applies. + + Raises + ------ + DriverError + If a keyword is given beside settings that declare their own, or + ``absorber=False`` beside a scheme. + + Examples + -------- + >>> from solprop.driver import SimulationSettings + >>> from solprop.grid import EnvelopeGrid + >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1e-12) + >>> settings = SimulationSettings(save_planes=[1.0], dz_max=0.1, dz_init=0.01) + >>> settings.with_boundaries(grid, boundary_check=None).boundary_check is None + True + """ + changes: dict[str, object] = {} + if absorber is False: + if absorber_scheme is not None: + msg = ( + "absorber=False switches the temporal collar off, so there is " + f"nothing for absorber_scheme={absorber_scheme!r} to apply; give " + "one or the other" + ) + raise DriverError(msg) + current = self.absorber + changes["absorber"] = ( + no_absorber(grid) + if current is None + else dataclasses.replace( + current, + spectral_rate=np.zeros_like(current.spectral_rate), + user_rate=None, + temporal_rate=np.zeros(grid.n_t), + temporal_rate_fine=np.zeros(grid.n_to), + temporal=False, + ) + ) + elif absorber is not None: + if self.absorber is not None: + msg = ( + "the settings already declare an absorber; give it there or as " + "the absorber keyword, not both" + ) + raise DriverError(msg) + changes["absorber"] = absorber + if absorber_scheme is not None: + if self.absorber_scheme_named and self.absorber_scheme != absorber_scheme: + msg = ( + f"the settings already declare absorber_scheme=" + f"{self.absorber_scheme!r}; give it there or as the keyword" + ) + raise DriverError(msg) + changes["absorber_scheme"] = absorber_scheme + if not ( + boundary_check is DEFAULT_BOUNDARY_CHECK + or boundary_check == self.boundary_check + ): + if self.boundary_check != DEFAULT_BOUNDARY_CHECK: + msg = ( + "the settings already declare a boundary_check; give it there or " + "as the boundary_check keyword, not both" + ) + raise DriverError(msg) + changes["boundary_check"] = boundary_check + if spatial_absorber is not None and spatial_absorber != self.spatial_absorber: + if self.spatial_absorber is not None: + msg = ( + "the settings already declare a spatial_absorber; give it there or " + "as the spatial_absorber keyword, not both" + ) + raise DriverError(msg) + changes["spatial_absorber"] = spatial_absorber + return dataclasses.replace(self, **changes) if changes else self + def _admit_tdse_execution(settings: SimulationSettings) -> None: """Refuse TDSE execution resources beside settings a TDSE run cannot step with. @@ -685,22 +795,67 @@ def _quiet_collar(settings: SimulationSettings) -> bool: return check is not None and check.quiet_collar -def _keyword_controller( - route: str, rtol: float | None, atol: float -) -> tuple[PIController, PIController | None]: - """Return a keyword interface's controller, and it again if its rtol is default. +def keyword_settings( + route: str, + length: float, + *, + rtol: float | None, + atol: float, + dz_max: float | None, + dz_init: float | None, + # The other settings fields, each typed on SimulationSettings itself. + **fields: Any, +) -> SimulationSettings: + """Return the settings a keyword route builds: its controller and its steps. + + Parameters + ---------- + route : str + The row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the route reads. + length : float + The guide's length, m, which the default steps are fractions of. + rtol : float or None + The caller's relative tolerance, which is never raised silently; ``None`` + takes the route's (:func:`~solprop.driver.defaults.default_rtol`), and the + settings record that the controller is the default. + atol : float + The absolute tolerance, in the route's field unit. + dz_max, dz_init : float or None + The largest and the first step, m; ``None`` takes + :func:`~solprop.driver.defaults.default_step`, and a first step so chosen is + refined by the start-up rule. + **fields : Any + Every other field of :class:`SimulationSettings`. + + Returns + ------- + SimulationSettings + The settings. - - ``route``: the row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the - interface reads. - - ``rtol``: the caller's relative tolerance, which is never raised silently; - ``None`` takes the route's (:func:`~solprop.driver.defaults.default_rtol`). - - Returns: the controller, and the same object where `rtol` was the route's default - so that the settings record the default, else ``None``. + Examples + -------- + >>> from solprop.driver.settings import keyword_settings + >>> settings = keyword_settings( + ... "gnlse", 0.4, rtol=None, atol=1e-14, dz_max=None, dz_init=None, + ... save_planes=[0.4], + ... ) + >>> settings.controller.rtol, settings.dz_max, settings.controller_defaulted + (1e-07, 0.2, True) """ - if rtol is not None: - return PIController(rtol=rtol, atol=atol), None - controller = PIController(rtol=default_rtol(route), atol=atol) - return controller, controller + if rtol is None: + controller = PIController(rtol=default_rtol(route), atol=atol) + default = controller + else: + controller, default = PIController(rtol=rtol, atol=atol), None + return SimulationSettings( + controller=controller, + _default_controller=default, + _route=route, + dz_max=default_step("dz_max", length) if dz_max is None else dz_max, + dz_init=default_step("dz_init", length) if dz_init is None else dz_init, + auto_init=dz_init is None, + **fields, + ) LOW_STORAGE_BACKENDS = ("native", "numpy_ref") @@ -834,94 +989,6 @@ def _spatial_settings(settings: SimulationSettings, route: str) -> SimulationSet return dataclasses.replace(settings, _route=route) -def _with_boundary( - settings: SimulationSettings, - grid: TimeGrid, - absorber: Absorber | Literal[False] | None, - scheme: AbsorberScheme | None, -) -> SimulationSettings: - """Apply a spatial interface's absorber keywords to its settings (SEP 21 section 5). - - - ``absorber``: the collar given; ``False`` zeroes the temporal and spectral rates - of the settings' absorber and keeps its lengths (without one it builds the hard - mask alone); ``None`` leaves the settings' own. The transverse boundary is - ``spatial_absorber``'s (SEP 40). - - ``scheme``: the temporal scheme given; ``None`` leaves the settings' own. - """ - changes: dict[str, object] = {} - if absorber is False: - if scheme is not None: - msg = ( - "absorber=False switches the temporal collar off, so there is nothing " - f"for absorber_scheme={scheme!r} to apply; give one or the other" - ) - raise DriverError(msg) - current = settings.absorber - changes["absorber"] = ( - no_absorber(grid) - if current is None - else dataclasses.replace( - current, - spectral_rate=np.zeros_like(current.spectral_rate), - user_rate=None, - temporal_rate=np.zeros(grid.n_t), - temporal_rate_fine=np.zeros(grid.n_to), - temporal=False, - ) - ) - elif absorber is not None: - if settings.absorber is not None: - msg = ( - "the settings already declare an absorber; give it there or as the " - "absorber keyword, not both" - ) - raise DriverError(msg) - changes["absorber"] = absorber - if scheme is not None: - if settings.absorber_scheme_named and settings.absorber_scheme != scheme: - msg = ( - f"the settings already declare absorber_scheme=" - f"{settings.absorber_scheme!r}; give it there or as the keyword" - ) - raise DriverError(msg) - changes["absorber_scheme"] = scheme - return dataclasses.replace(settings, **changes) if changes else settings - - -def _watched( - settings: SimulationSettings, check: BoundaryCheck | None -) -> SimulationSettings: - """Apply a spatial constructor's ``boundary_check`` keyword to its settings.""" - if check is DEFAULT_BOUNDARY_CHECK or check == settings.boundary_check: - return settings - if settings.boundary_check != DEFAULT_BOUNDARY_CHECK: - msg = ( - "the settings already declare a boundary_check; give it there or as the " - "boundary_check keyword, not both" - ) - raise DriverError(msg) - return dataclasses.replace(settings, boundary_check=check) - - -def _bounded( - settings: SimulationSettings, - spatial_absorber: SpatialAbsorber | Literal[False] | None, -) -> SimulationSettings: - """Apply a spatial constructor's ``spatial_absorber`` keyword to its settings. - - - ``spatial_absorber``: the keyword's value; ``None`` leaves the settings' own. - """ - if spatial_absorber is None or spatial_absorber == settings.spatial_absorber: - return settings - if settings.spatial_absorber is not None: - msg = ( - "the settings already declare a spatial_absorber; give it there or as " - "the spatial_absorber keyword, not both" - ) - raise DriverError(msg) - return dataclasses.replace(settings, spatial_absorber=spatial_absorber) - - def _boundary( absorber: Absorber | Literal[False] | None, scheme: AbsorberScheme | None, diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 5b773e81..b2787f9b 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -129,16 +129,13 @@ NoiseSetting, SimulationSettings, _boundary, - _bounded, - _keyword_controller, _quiet_collar, _same_setting, _save_planes, _scheme, _spatial_default, _spatial_settings, - _watched, - _with_boundary, + keyword_settings, ) from solprop.driver.tdse_run import ( _AtomicContext, @@ -2096,12 +2093,12 @@ def from_radial( _selected_medium(medium, materials), responses, initial, - settings=_bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, + settings=settings.with_boundaries( + grid, + absorber=absorber, + absorber_scheme=absorber_scheme, + boundary_check=boundary_check, + spatial_absorber=spatial_absorber, ), frame=frame, noise=noise, @@ -2216,12 +2213,12 @@ def from_free( medium, responses, initial, - settings=_bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, + settings=settings.with_boundaries( + grid, + absorber=absorber, + absorber_scheme=absorber_scheme, + boundary_check=boundary_check, + spatial_absorber=spatial_absorber, ), frame=frame, noise=noise, @@ -2339,12 +2336,12 @@ def from_planar( # Refused before the simulation copies it. _check_planar_input(initial.shape, space.n_columns, grid.n_omega) settings = _spatial_default( - _bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, + settings.with_boundaries( + grid, + absorber=absorber, + absorber_scheme=absorber_scheme, + boundary_check=boundary_check, + spatial_absorber=spatial_absorber, ), grid, ) @@ -2624,17 +2621,14 @@ def from_gnlse( pulses=pulses, ) boundary, scheme = _boundary(absorber, absorber_scheme, working) - controller, default = _keyword_controller("gnlse", rtol, atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route="gnlse", + settings = keyword_settings( + "gnlse", + length, + rtol=rtol, + atol=atol, + dz_max=dz_max, + dz_init=dz_init, save_planes=_save_planes(length, save_planes, n_save), - # Relative defaults, so that the first step and the cap scale with the - # fibre. - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, schedule=schedule, integrator=integrator, interpolate_planes=interpolate_planes, diff --git a/src/solprop/recipe/metadata.py b/src/solprop/recipe/metadata.py index 5fb7af50..1e55c232 100644 --- a/src/solprop/recipe/metadata.py +++ b/src/solprop/recipe/metadata.py @@ -25,10 +25,9 @@ from typing import TYPE_CHECKING, Final, Literal from solprop.driver.defaults import ( - default_integrator, - default_rtol, default_scheme, - resolve_noise, + describe_default, + route_row, ) from solprop.exceptions import DriverError from solprop.modes.slab import SlabMode @@ -319,52 +318,31 @@ def _row(route: str, values: Mapping[str, object]) -> str: - Returns: ``"capillary tdse"`` for a capillary beside a TDSE response, and ``"waveguide slab"`` for a waveguide of slab modes; the route otherwise. """ - if route == "capillary" and values.get("tdse") is not None: - return "capillary tdse" - if per_unit_width(route, values): - return "waveguide slab" - return route - - -def _rtol(route: str) -> Resolver: - """Return the resolver of a route's ``rtol``.""" - - def resolve(values: Mapping[str, object]) -> Resolution: - """Return the route's default.""" - row = _row(route, values) - return Resolution( - default_rtol(row), - f"the {row} route's default (solprop.driver.defaults.ROUTE_DEFAULTS)", - ) - - return resolve - - -def _integrator(route: str) -> Resolver: - """Return the resolver of a route's ``integrator``.""" - - def resolve(values: Mapping[str, object]) -> Resolution: - """Return the route's default method.""" - row = _row(route, values) - return Resolution(default_integrator(row), f"the {row} route's default") + return route_row( + route, + tdse=route == "capillary" and values.get("tdse") is not None, + slab=per_unit_width(route, values), + ) - return resolve +def _route_default( + route: str, keyword: Literal["rtol", "integrator", "noise", "dz_max", "dz_init"] +) -> Resolver: + """Return the resolver of a keyword whose default is the route's own. -def _noise(route: str) -> Resolver: - """Return the resolver of a route's ``noise="default"``.""" + The value and the rule are :func:`solprop.driver.defaults.describe_default`'s, for + the defaults-table row the recipe's values select. + """ def resolve(values: Mapping[str, object]) -> Resolution: - """Return the model the default draws, and why. - - - Returns: the model (``None`` for none) and the reason. - """ - row = _row(route, values) - model, resolution = resolve_noise(row, "default", None) - per = "transverse column" if route in OBJECT_ROUTES else "mode" - return Resolution( - model, resolution.reason or f"one photon per {per} on the input" + """Return the row's default for the keyword, and its rule.""" + length = values.get("length") + value, rule = describe_default( + _row(route, values), + keyword, + length if isinstance(length, int | float) else None, ) + return Resolution(value, rule) return resolve @@ -439,24 +417,6 @@ def _pressure(values: Mapping[str, object]) -> Resolution: return Resolution(1.0, "one bar when no pressure is given") -def _length_fraction(fraction: float, text: str) -> Resolver: - """Return the resolver of a step length. - - The step defaults to `fraction` of ``length``; `text` is the rule. - """ - - def resolve(values: Mapping[str, object]) -> Resolution: - """Return the fraction of the recipe's length. - - - Returns: the step, m, or ``None`` without a length, and the rule. - """ - length = values.get("length") - value = None if not isinstance(length, int | float) else fraction * length - return Resolution(value, text) - - return resolve - - # ------------------------------------------------------------------- the forms _PROFILES: Final = ("Constant", "Linear", "PiecewiseLinear") @@ -629,7 +589,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: "Shot noise added to the input: a model, or none.", _NOISE_PAGE, none=True, - resolver=_noise(route), + resolver=_route_default(route, "noise"), ), Parameter( "seed", @@ -647,7 +607,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: "Relative tolerance of the adaptive step.", _STEPPER_PAGE, resolvable=True, - resolver=_rtol(route), + resolver=_route_default(route, "rtol"), ), Parameter( "atol", @@ -667,7 +627,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: _PLANES_PAGE, display="mm", resolvable=True, - resolver=_length_fraction(0.5, "half the length"), + resolver=_route_default(route, "dz_max"), ), Parameter( "dz_init", @@ -677,7 +637,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: "First step; none takes it from the start-up rule.", _PLANES_PAGE, display="mm", - resolver=_length_fraction(1e-4, "a ten-thousandth of the length"), + resolver=_route_default(route, "dz_init"), ), Parameter( "schedule", @@ -695,7 +655,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: "The method that steps the run.", _STEPPER_PAGE, resolvable=True, - resolver=_integrator(route), + resolver=_route_default(route, "integrator"), ), Parameter( "interpolate_planes", diff --git a/tests/driver/test_absorber_keywords.py b/tests/driver/test_absorber_keywords.py index e9b14f40..64bc3a33 100644 --- a/tests/driver/test_absorber_keywords.py +++ b/tests/driver/test_absorber_keywords.py @@ -21,7 +21,7 @@ from solprop.boundaries import absorber, custom_absorber, no_absorber from solprop.driver import Simulation, SimulationSettings -from solprop.driver.settings import _spatial_default, _with_boundary +from solprop.driver.settings import _spatial_default from solprop.exceptions import DriverError from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, RadialGrid from solprop.modes import PlanarGrid, SlabMode @@ -118,7 +118,7 @@ def _settings(grid: FieldGrid | EnvelopeGrid, **keywords: Any) -> SimulationSett def test_spatial_absorber_false_keeps_the_lengths(grid: FieldGrid) -> None: """``False`` zeroes the settings' collar's rates and keeps its lengths.""" collar = dataclasses.replace(absorber(grid), collar_length=0.02) - settings = _with_boundary(_settings(grid, absorber=collar), grid, False, None) + settings = _settings(grid, absorber=collar).with_boundaries(grid, absorber=False) off = settings.absorber assert off is not None assert not np.any(off.spectral_rate) @@ -126,7 +126,7 @@ def test_spatial_absorber_false_keeps_the_lengths(grid: FieldGrid) -> None: assert off.in_stage_length == collar.in_stage_length assert off.reference_length == collar.reference_length # The settings carry none: the hard mask alone. - bare = _with_boundary(_settings(grid), grid, False, None).absorber + bare = _settings(grid).with_boundaries(grid, absorber=False).absorber assert bare is not None assert not np.any(bare.spectral_rate) assert not np.any(bare.temporal_rate) @@ -189,16 +189,20 @@ def test_spatial_builders_take_the_absorber_keywords( def test_spatial_keywords_refuse_a_contradiction(grid: FieldGrid) -> None: """A keyword may fill in the settings, never overrule them.""" with pytest.raises(DriverError, match="already declare an absorber"): - _with_boundary( - _settings(grid, absorber=absorber(grid)), grid, absorber(grid), None + _settings(grid, absorber=absorber(grid)).with_boundaries( + grid, absorber=absorber(grid) ) with pytest.raises(DriverError, match="absorber_scheme"): - _with_boundary( - _settings(grid, absorber_scheme="split_step"), grid, None, "nonlinear" + _settings(grid, absorber_scheme="split_step").with_boundaries( + grid, absorber_scheme="nonlinear" ) with pytest.raises(DriverError, match="absorber_scheme"): - _with_boundary(_settings(grid), grid, False, "split_step") - same = _with_boundary(_settings(grid), grid, absorber(grid), "split_step") + _settings(grid).with_boundaries( + grid, absorber=False, absorber_scheme="split_step" + ) + same = _settings(grid).with_boundaries( + grid, absorber=absorber(grid), absorber_scheme="split_step" + ) assert same.absorber_scheme == "split_step" assert same.absorber is not None @@ -283,7 +287,7 @@ def test_absorber_false_clears_a_custom_rate_with_the_total(): controller=PIController(rtol=1e-6, atol=1e-12), absorber=custom_absorber(grid, 3.0), ) - cleared = _with_boundary(settings, grid, absorber=False, scheme=None) + cleared = settings.with_boundaries(grid, absorber=False) assert cleared.absorber is not None assert cleared.absorber.user_rate is None assert not np.any(cleared.absorber.spectral_rate) diff --git a/tests/recipe/test_metadata.py b/tests/recipe/test_metadata.py index cf6540df..a07bdfbd 100644 --- a/tests/recipe/test_metadata.py +++ b/tests/recipe/test_metadata.py @@ -25,7 +25,6 @@ ) from solprop.driver.settings import SimulationSettings from solprop.exceptions import DriverError -from solprop.modes import MarcatiliMode, ModeCollection from solprop.recipe import metadata from solprop.recipe.document import EXECUTION_KEYS, REFUSED_BUILD_KEYS from solprop.recipe.quantity import Unit @@ -189,52 +188,6 @@ def test_the_resolvable_keywords_are_those_the_sep_measured(): assert entry is None or not entry.resolvable -GNLSE = { - "wavelength": 835e-9, - "wavelength_limits": (500e-9, 2e-6), - "trange": 1e-12, - "width": 100e-15, - "peak_power": 9.8, - "shape": "sech", - "n_save": 3, - "backend": "numpy_ref", -} -CAPILLARY = { - "wavelength": 800e-9, - "wavelength_limits": (400e-9, 2e-6), - "trange": 200e-15, - "fwhm": 30e-15, - "energy": 1e-6, - "n_save": 2, - "backend": "numpy_ref", -} - - -def test_the_resolvers_say_what_the_build_resolves(): - gnlse = Simulation.from_gnlse(0.11, 0.01, [0.0, 0.0, -1.183e-26], **GNLSE) - capillary = Simulation.from_capillary(125e-6, 0.1, "He", 1.0, **CAPILLARY) - hollow = [MarcatiliMode(125e-6)] - waveguide = Simulation.from_waveguide(hollow, 0.1, "He", 1.0, **CAPILLARY) - slab = ModeCollection.slab(50e-6, (1,)) - planar = Simulation.from_waveguide(slab, 0.1, "Ar", 1.0, **CAPILLARY) - for route, simulation, modes in ( - ("gnlse", gnlse, None), - ("capillary", capillary, None), - ("waveguide", waveguide, hollow), - ("waveguide", planar, slab), - ): - table = metadata.parameters(route) - values = {"modes": modes} - settings = simulation.settings - controller = settings.controller - assert controller is not None - assert _resolve(table, "rtol", values).value == controller.rtol - integrator = _resolve(table, "integrator", values).value - assert type(integrator) is type(settings.integrator) - model = _resolve(table, "noise", values).value - assert model == simulation.noise_resolution.resolved - - def test_the_capillary_s_rules_follow_the_envelope_and_the_tdse(): table = metadata.parameters("capillary") assert _resolve(table, "thg", {}).value is True From 6d441f7fd522562888c5f95aae9df4799e8a4790 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:06:28 +0000 Subject: [PATCH 17/42] Share the service's payload reader and dispersion-curve helpers One read_recipe(payload) and Payload alias in service/_payload.py replace three _recipe copies and eight aliases; the curve range, axis reach, zero and dispersive-wave readings move from sheet.py to service/_curves.py, so waveguide.py imports shared names instead of sheet's privates. The three unrelated _plain functions become _plain, _framed and _point_value. Co-Authored-By: Claude Opus 5.5 --- src/solprop/service/_curves.py | 152 +++++++++++++++++++++ src/solprop/service/_payload.py | 26 ++++ src/solprop/service/beams.py | 4 +- src/solprop/service/design.py | 42 +++--- src/solprop/service/figures.py | 4 +- src/solprop/service/frames.py | 10 +- src/solprop/service/handlers.py | 25 +--- src/solprop/service/postprocess.py | 3 +- src/solprop/service/results.py | 2 +- src/solprop/service/run.py | 4 +- src/solprop/service/sheet.py | 187 ++++---------------------- src/solprop/service/waveguide.py | 32 ++--- tests/service/test_scan_reductions.py | 4 +- tests/service/test_sheet.py | 7 +- 14 files changed, 261 insertions(+), 241 deletions(-) create mode 100644 src/solprop/service/_curves.py create mode 100644 src/solprop/service/_payload.py diff --git a/src/solprop/service/_curves.py b/src/solprop/service/_curves.py new file mode 100644 index 00000000..dc8bfb16 --- /dev/null +++ b/src/solprop/service/_curves.py @@ -0,0 +1,152 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The dispersion curves the design sheets draw: their window, axis and readings. + +The capillary, fibre and waveguide sheets (:mod:`solprop.service.sheet`, +:mod:`solprop.service.waveguide`) draw a guide's dispersion over the run's own window +and read the zero and the dispersive wave from it. +""" + +import math + +import numpy as np + +from solprop.constants import C +from solprop.grid import EnvelopeGrid +from solprop.recipe import Recipe +from solprop.tools import lambda_rdw_all + +__all__ = [ + "CURVE_POINTS", + "curve_range", + "reach_of", + "shortest_wave", + "wave_window", + "zero_near", +] + +CURVE_POINTS = 240 +"""The wavelengths of a sheet's dispersion curve.""" + +_NEAR_PUMP = 0.35 +"""How far from the pump, as a share of the frequency, a curve's axis is judged.""" + +_NEAR_REACH = 3.0 +"""How far past the pump's neighbourhood a curve's axis may reach, as a multiple.""" + +_CURVE_WINDOW = (0.15, 2.0) +"""The curve's wavelengths with no window in the recipe, as fractions of the pump's.""" + + +def curve_range(recipe: Recipe, pump: float) -> tuple[float, float]: + """Return the wavelengths a dispersion curve covers: the run's own window. + + - ``pump``: the pump's wavelength, m. + - Returns: the two ends, m. + """ + keywords = recipe.keywords() + limits = keywords.get("wavelength_limits") + if isinstance(limits, tuple | list) and len(limits) == 2: + return float(limits[0]), float(limits[1]) # pyright: ignore[reportArgumentType] + grid = keywords.get("grid") + if isinstance(grid, EnvelopeGrid): + return 2.0 * math.pi * C / grid.omega_max, 2.0 * math.pi * C / grid.omega_min + return pump * _CURVE_WINDOW[0], pump * _CURVE_WINDOW[1] + + +def reach_of(values: np.ndarray, wavelength: np.ndarray, pump: float) -> float: + """Return how far a dispersion curve's axis should reach from zero. + + The curve runs off near a resonance, and a fibre's resonance dwarfs the pump's + neighbourhood, so the axis follows what the eye reads there. + + - ``wavelength``: its wavelengths, m, ascending. + - ``pump``: the pump's wavelength, m. + """ + reach = float(np.nanmax(np.abs(values[wavelength > 0.25 * wavelength[-1]]))) + omega, centre = 1.0 / wavelength, 1.0 / pump + near = np.abs(omega - centre) < _NEAR_PUMP * omega + around = float(np.nanmax(np.abs(values[near]))) if near.any() else reach + return max(min(reach, _NEAR_REACH * around), 1.0) * 1.05 + + +def zero_near(wavelength: np.ndarray, beta2: np.ndarray, pump: float) -> float | None: + """Return the zero of a dispersion curve nearest a pump, read from the curve. + + - ``wavelength``: the curve's wavelengths, m, ascending. + - ``pump``: the pump's wavelength, m. + - Returns: the wavelength, m; ``None`` where the curve does not cross zero. + """ + crossings = np.flatnonzero(np.sign(beta2[:-1]) * np.sign(beta2[1:]) < 0) + zeros = [ + float( + wavelength[i] + + (wavelength[i + 1] - wavelength[i]) * beta2[i] / (beta2[i] - beta2[i + 1]) + ) + for i in crossings + ] + return min(zeros, key=lambda at: abs(at - pump), default=None) + + +def wave_window(recipe: Recipe, pump: float) -> tuple[float, float] | None: + """Return where to look for a dispersive wave in an antiresonant fibre. + + The tool's own range reaches where this fibre's fits go non-finite (#538), so the + search keeps to the run's window; a capillary keeps the tool's. + + - ``pump``: the pump's wavelength, m. + - Returns: the range, m; ``None`` for the tool's own. + """ + if recipe.keywords().get("lattice") is None: + return None + return curve_range(recipe, pump)[0], 0.9 * pump + + +def shortest_wave( + mode: object, + wavelength: float, + *, + beta2: float, + peak_power: float, + gamma: float, + fwhm: float, + shape: str, + limits: tuple[float, float] | None = None, +) -> float | None: + """Return the shortest wavelength phase-matched to the launch soliton. + + - ``wavelength``: the pump's wavelength, m. + - ``beta2``: the dispersion at the pump, s^2/m. + - ``peak_power``: the peak power, W. + - ``gamma``: the nonlinear coefficient, 1/(W m). + - ``fwhm``: the duration, s. + - ``shape``: the pulse's shape. + - ``limits``: the range to search, m; ``None`` takes the tool's. + - Returns: the wavelength, m; ``None`` when none is, as a pump in normal dispersion + launches no soliton. + """ + if beta2 >= 0: + return None + roots = ( + lambda_rdw_all( + mode, # pyright: ignore[reportArgumentType] + wavelength, + peak_power=peak_power, + gamma=gamma, + fwhm=fwhm, + shape=shape, # pyright: ignore[reportArgumentType] + ) + if limits is None + else lambda_rdw_all( + mode, # pyright: ignore[reportArgumentType] + wavelength, + peak_power=peak_power, + gamma=gamma, + fwhm=fwhm, + shape=shape, # pyright: ignore[reportArgumentType] + wavelength_limits=limits, + ) + ) + return float(roots[0]) if len(roots) else None diff --git a/src/solprop/service/_payload.py b/src/solprop/service/_payload.py new file mode 100644 index 00000000..5b08972a --- /dev/null +++ b/src/solprop/service/_payload.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A request's payload, and the recipe it carries.""" + +from collections.abc import Mapping +from pathlib import Path + +from solprop.recipe import Recipe + +__all__ = ["Payload", "base_of", "read_recipe"] + +type Payload = Mapping[str, object] +"""What a request carries: plain data, keyed by name.""" + + +def base_of(payload: Payload) -> Path | None: + """Return the directory a payload's recipe reads its data files from, if any.""" + base = payload.get("base") + return Path(str(base)) if base else None + + +def read_recipe(payload: Payload) -> Recipe: + """Read the recipe a payload carries: ``text``, its TOML, and ``base`` if any.""" + return Recipe.loads(str(payload["text"]), base=base_of(payload)) diff --git a/src/solprop/service/beams.py b/src/solprop/service/beams.py index 2e9deacd..5064d7ed 100644 --- a/src/solprop/service/beams.py +++ b/src/solprop/service/beams.py @@ -4,8 +4,6 @@ """What a radial beam's pages show, read from its file with solprop's readers.""" -from collections.abc import Mapping - import numpy as np from solprop.output import open_file @@ -19,6 +17,7 @@ radial_intensity, radial_spectrum, ) +from solprop.service._payload import Payload from solprop.service.reduce import TIME_OVERSAMPLING, even_rows from solprop.service.results import _window @@ -32,7 +31,6 @@ "beam_plane", ] -Payload = Mapping[str, object] _ROWS = 400 """The spectral bins of a beam's maps, even on their axis.""" diff --git a/src/solprop/service/design.py b/src/solprop/service/design.py index 48e4bb14..52609ea6 100644 --- a/src/solprop/service/design.py +++ b/src/solprop/service/design.py @@ -20,29 +20,17 @@ from solprop.recipe import Recipe from solprop.recipe.pins import GUIDE_PINS, PULSE_PINS, TARGETS from solprop.recipe.quantity import Unit, parse_quantity +from solprop.service._payload import Payload, base_of, read_recipe from solprop.tools import pressure_zdw -__all__ = ["DESIGN_HANDLERS", "modes", "pin_roots", "pin_targets", "zdw_chart"] - -Payload = Mapping[str, object] - - -def _base(payload: Payload) -> Path | None: - """Return the directory a payload's recipe reads its data files from. - - - ``payload``: optionally ``base``. - - Returns: the directory, or ``None`` for none. - """ - base = payload.get("base") - return Path(str(base)) if base else None - - -def _recipe(payload: Payload) -> Recipe: - """Read the recipe a payload carries. - - - ``payload``: ``text``, the recipe's TOML, and optionally ``base``. - """ - return Recipe.loads(str(payload["text"]), base=_base(payload)) +__all__ = [ + "DESIGN_HANDLERS", + "PRESSURE_ZDW_KEYWORDS", + "modes", + "pin_roots", + "pin_targets", + "zdw_chart", +] def _with(recipe: Recipe, name: str, value: object, base: Path | None) -> Recipe: @@ -125,7 +113,7 @@ def modes(payload: Payload) -> dict[str, object]: ``value`` (the swept value, or ``None``) with the mode set (:func:`_mode_set`) or the ``error`` that value met. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) z = payload.get("z") position = None if z is None else parse_quantity(z, Unit("m"), path="z") sweep = payload.get("sweep") @@ -137,7 +125,7 @@ def modes(payload: Payload) -> dict[str, object]: variants = [] for value in values: try: - variants.append((value, _with(recipe, name, value, _base(payload)))) + variants.append((value, _with(recipe, name, value, base_of(payload)))) except SolpropError as error: variants.append((value, error)) sets: list[dict[str, object]] = [] @@ -210,7 +198,7 @@ def pin_roots(payload: Payload) -> dict[str, object]: ``attainable`` measures) and ``attainable`` (the range of it reached when no root is found, or ``None``). """ - found = _recipe(payload).pin_roots(str(payload["path"])) + found = read_recipe(payload).pin_roots(str(payload["path"])) return { "roots": [float(root) for root in found.roots], "search": [float(end) for end in found.search], @@ -221,7 +209,7 @@ def pin_roots(payload: Payload) -> dict[str, object]: } -_CHART_KEYWORDS = ("temperature", "model", "cladding", "materials") +PRESSURE_ZDW_KEYWORDS = ("temperature", "model", "cladding", "materials") """The capillary's keywords that ``tools.pressure_zdw`` also takes, by name.""" CHART_POINTS = 400 @@ -258,7 +246,7 @@ def zdw_chart(payload: Payload) -> dict[str, object]: ValueError If the chart asks for more than :data:`CHART_POINTS` pressures. """ - keywords = _recipe(payload).resolve().keywords() + keywords = read_recipe(payload).resolve().keywords() length = Unit("m") radii = [parse_quantity(r, length, path="radii") for r in payload["radii"]] # pyright: ignore[reportGeneralTypeIssues] wavelengths = [ @@ -270,7 +258,7 @@ def zdw_chart(payload: Payload) -> dict[str, object]: raise ValueError(msg) shared = { name: keywords[name] - for name in _CHART_KEYWORDS + for name in PRESSURE_ZDW_KEYWORDS if keywords.get(name) is not None } pressure_max = float(payload.get("pressure_max") or 100.0) # pyright: ignore[reportArgumentType] diff --git a/src/solprop/service/figures.py b/src/solprop/service/figures.py index 46ed5251..44b02877 100644 --- a/src/solprop/service/figures.py +++ b/src/solprop/service/figures.py @@ -16,13 +16,13 @@ import numpy as np from matplotlib.figure import Figure +from solprop.service._payload import Payload + __all__ = ["FIGURE_HANDLERS", "figure"] FORMATS = (".png", ".svg", ".pdf") """The file types a figure is written as.""" -Payload = Mapping[str, object] - def figure(payload: Payload) -> str: """Draw a view's description and write it. diff --git a/src/solprop/service/frames.py b/src/solprop/service/frames.py index 1cdc3cfc..6824e2d1 100644 --- a/src/solprop/service/frames.py +++ b/src/solprop/service/frames.py @@ -93,7 +93,7 @@ def flush(self) -> None: """The largest header or body accepted, bytes: a guard against a corrupt length.""" -def _plain(value: object, arrays: list[np.ndarray]) -> object: +def _framed(value: object, arrays: list[np.ndarray]) -> object: """Return a value's JSON form, collecting its arrays.""" if value is None or isinstance(value, bool | int | str): return value @@ -107,17 +107,17 @@ def _plain(value: object, arrays: list[np.ndarray]) -> object: arrays.append(value if value.flags.c_contiguous else value.copy(order="C")) return {_ARRAY: len(arrays) - 1} if isinstance(value, np.generic): - return _plain(value.item(), arrays) + return _framed(value.item(), arrays) if isinstance(value, Mapping): plain: dict[str, object] = {} for key, item in value.items(): # pyright: ignore[reportUnknownVariableType] if not isinstance(key, str): msg = f"a frame's keys are strings, not {type(key).__name__}" # pyright: ignore[reportUnknownArgumentType] raise ServiceError(msg) - plain[key] = _plain(item, arrays) + plain[key] = _framed(item, arrays) return plain if isinstance(value, list | tuple): - return [_plain(item, arrays) for item in value] # pyright: ignore[reportUnknownVariableType] + return [_framed(item, arrays) for item in value] # pyright: ignore[reportUnknownVariableType] msg = f"a frame carries plain data and arrays, not {type(value).__name__}" raise ServiceError(msg) @@ -151,7 +151,7 @@ def encode(value: object) -> bytes: (0.5, [0.0, 1.0, 2.0], True) """ arrays: list[np.ndarray] = [] - plain = _plain(value, arrays) + plain = _framed(value, arrays) blocks: list[dict[str, object]] = [] offset = 0 for array in arrays: diff --git a/src/solprop/service/handlers.py b/src/solprop/service/handlers.py index bf4989bf..30288c98 100644 --- a/src/solprop/service/handlers.py +++ b/src/solprop/service/handlers.py @@ -27,6 +27,7 @@ from solprop.recipe.metadata import REQUIRED, per_unit_width from solprop.recipe.quantity import Unit, format_quantity from solprop.recipe.registry import REGISTRY +from solprop.service._payload import Payload, read_recipe from solprop.service.beams import BEAM_HANDLERS from solprop.service.design import DESIGN_HANDLERS, pin_targets from solprop.service.figures import FIGURE_HANDLERS @@ -45,18 +46,6 @@ __all__ = ["HANDLERS"] -Payload = Mapping[str, object] - - -def _recipe(payload: Payload) -> Recipe: - """Read the recipe a payload carries. - - - ``payload``: ``text``, the recipe's TOML, and optionally ``base``, the directory - its data files resolve against. - """ - base = payload.get("base") - return Recipe.loads(str(payload["text"]), base=Path(str(base)) if base else None) - def _plain(value: object) -> object: """Return a value as plain data, or its text where it is not. @@ -245,7 +234,7 @@ def check(payload: Payload) -> list[dict[str, object]]: error at no parameter. """ try: - issues = _recipe(payload).check() + issues = read_recipe(payload).check() except SolpropError as error: return [ { @@ -291,7 +280,7 @@ def summarise(payload: Payload) -> dict[str, object]: pinned path, the solved value in its keyword's unit), and ``per_width``, whether the energy and peak power are per metre of y (a slab mode set). """ - authored = _recipe(payload) + authored = read_recipe(payload) recipe = authored.resolve() pinned = { path: _plain(recipe.get(path)) for path in authored.to_dict().get("pins", {}) @@ -347,7 +336,7 @@ def design(payload: Payload) -> dict[str, object]: solprop.exceptions.SolpropError If a pin has no root, or the pinned recipe does not read. """ - document = _recipe(payload).to_dict() + document = read_recipe(payload).to_dict() pins: Mapping[str, object] = payload["pins"] # pyright: ignore[reportAssignmentType] build = document["build"] for name in pins: @@ -382,7 +371,7 @@ def preview_input(payload: Payload) -> dict[str, object]: spectrum's squared magnitude summed over modes), ``energy`` (J) and ``peak_power`` (W). """ - preview = _recipe(payload).preview_input() + preview = read_recipe(payload).preview_input() spectrum = np.abs(np.asarray(preview.spectrum)) ** 2 if spectrum.ndim > 1: spectrum = spectrum.reshape(-1, spectrum.shape[-1]).sum(axis=0) @@ -412,7 +401,7 @@ def estimate(payload: Payload) -> dict[str, object]: dict The estimate's fields. """ - result = _recipe(payload).build().estimate() + result = read_recipe(payload).build().estimate() return { "shape": list(result.shape), "n_planes": result.n_planes, @@ -441,7 +430,7 @@ def to_python(payload: Payload) -> str: The script. """ output = payload.get("output") - return _recipe(payload).to_python(None if output is None else str(output)) + return read_recipe(payload).to_python(None if output is None else str(output)) def check_resume(payload: Payload) -> dict[str, object]: diff --git a/src/solprop/service/postprocess.py b/src/solprop/service/postprocess.py index 0eed889d..aaf09ff3 100644 --- a/src/solprop/service/postprocess.py +++ b/src/solprop/service/postprocess.py @@ -52,13 +52,12 @@ ) from solprop.processing.compression import Compression from solprop.processing.time import fwhm +from solprop.service._payload import Payload from solprop.service.reduce import TIME_OVERSAMPLING from solprop.service.results import band_of __all__ = ["POSTPROCESS_HANDLERS", "catalogue", "compress_chain"] -Payload = Mapping[str, object] - def catalogue(_payload: Payload) -> dict[str, list[str]]: """Return the names a compressor's stages choose from. diff --git a/src/solprop/service/results.py b/src/solprop/service/results.py index 890e3eb7..17f8ffb5 100644 --- a/src/solprop/service/results.py +++ b/src/solprop/service/results.py @@ -46,6 +46,7 @@ from solprop.processing.spectra import bandpass as spectral_window from solprop.processing.time import energy, fwhm_t from solprop.recipe import Recipe +from solprop.service._payload import Payload from solprop.service.beam import beam_reading, beam_slice, profile_row from solprop.service.recipes import split, without_table from solprop.service.reduce import TIME_OVERSAMPLING, even_rows, light_range @@ -55,7 +56,6 @@ __all__ = ["RESULTS_HANDLERS", "band_of", "spatial_plane"] -Payload = Mapping[str, object] C = 299_792_458.0 diff --git a/src/solprop/service/run.py b/src/solprop/service/run.py index 854c174a..28bff13f 100644 --- a/src/solprop/service/run.py +++ b/src/solprop/service/run.py @@ -264,7 +264,7 @@ def point_started(self, index: int, point: object) -> None: """ values = getattr(point, "values", {}) # A keyword's value is read in SI; a nested path's is the quantity as written. - plain = {str(k): _plain(v) for k, v in dict(values).items()} # pyright: ignore[reportUnknownArgumentType] + plain = {str(k): _point_value(v) for k, v in dict(values).items()} # pyright: ignore[reportUnknownArgumentType] place = [int(i) for i in getattr(point, "multi_index", ())] self.emit( Event( @@ -365,7 +365,7 @@ def _ending(output: Path, stopped: bool) -> tuple[str, dict[str, object]]: return "failed", data -def _plain(value: object) -> float | str: +def _point_value(value: object) -> float | str: """Return a point's value as an event carries it. - ``value``: a number, or a quantity as a recipe writes it (``"0.8 bar"``). diff --git a/src/solprop/service/sheet.py b/src/solprop/service/sheet.py index f8649f8e..e320350f 100644 --- a/src/solprop/service/sheet.py +++ b/src/solprop/service/sheet.py @@ -16,14 +16,12 @@ import math import os from collections.abc import Mapping -from pathlib import Path import numpy as np from solprop.backends import ThreadBudget, available, thread_budget from solprop.constants import C from solprop.exceptions import SolpropError -from solprop.grid import EnvelopeGrid from solprop.modes import base from solprop.physdata.gases import Gas from solprop.plotting.geometry import LIGHT, Disc, Ring, outline @@ -32,6 +30,15 @@ from solprop.recipe.errors import RecipeError from solprop.recipe.metadata import parameters from solprop.recipe.quantity import Unit, parse_quantity +from solprop.service._curves import ( + CURVE_POINTS, + curve_range, + reach_of, + shortest_wave, + wave_window, + zero_near, +) +from solprop.service._payload import Payload, read_recipe from solprop.service.kernel import RUN_THREADS from solprop.tools import ( SolitonParams, @@ -40,7 +47,6 @@ capillary_params, dispersion_length, fission_length, - lambda_rdw_all, nonlinear_coefficient, nonlinear_length, pulse_peak_power, @@ -67,37 +73,12 @@ "run_options", ] -Payload = Mapping[str, object] - _SHARED = ("shape", "temperature", "model", "loss", "cladding", "materials") """The capillary's keywords that ``tools.capillary_params`` also takes, by name.""" -CURVE_POINTS = 240 -"""The wavelengths of a sheet's dispersion curve.""" - _DB_PER_NEPER = 10.0 / math.log(10.0) """What a power loss coefficient in 1/m is multiplied by to read in dB/m.""" -_NEAR_PUMP = 0.35 -"""How far from the pump, as a share of the frequency, a curve's axis is judged.""" - -_NEAR_REACH = 3.0 -"""How far past the pump's neighbourhood a curve's axis may reach, as a multiple.""" - -_CURVE_WINDOW = (0.15, 2.0) -"""The curve's wavelengths with no window in the recipe, as fractions of the pump's.""" - - -def _recipe(payload: Payload) -> Recipe: - """Read the recipe a payload carries. - - - ``payload``: ``text`` and optionally ``base``. - """ - base_dir = payload.get("base") - return Recipe.loads( - str(payload["text"]), base=None if base_dir is None else Path(str(base_dir)) - ) - def capillary_numbers(recipe: Recipe, energy: float) -> SolitonParams: """Return ``tools.capillary_params`` at a capillary's entrance. @@ -181,86 +162,6 @@ def _as_numbers(params: SolitonParams) -> dict[str, float]: } -def _shortest_wave( - mode: object, - wavelength: float, - *, - beta2: float, - peak_power: float, - gamma: float, - fwhm: float, - shape: str, - limits: tuple[float, float] | None = None, -) -> float | None: - """Return the shortest wavelength phase-matched to the launch soliton. - - - ``wavelength``: the pump's wavelength, m. - - ``beta2``: the dispersion at the pump, s^2/m. - - ``peak_power``: the peak power, W. - - ``gamma``: the nonlinear coefficient, 1/(W m). - - ``fwhm``: the duration, s. - - ``shape``: the pulse's shape. - - ``limits``: the range to search, m; ``None`` takes the tool's. - - Returns: the wavelength, m; ``None`` when none is, as a pump in normal dispersion - launches no soliton. - """ - if beta2 >= 0: - return None - roots = ( - lambda_rdw_all( - mode, # pyright: ignore[reportArgumentType] - wavelength, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, # pyright: ignore[reportArgumentType] - ) - if limits is None - else lambda_rdw_all( - mode, # pyright: ignore[reportArgumentType] - wavelength, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, # pyright: ignore[reportArgumentType] - wavelength_limits=limits, - ) - ) - return float(roots[0]) if len(roots) else None - - -def _wave_window(recipe: Recipe, pump: float) -> tuple[float, float] | None: - """Return where to look for a dispersive wave in an antiresonant fibre. - - The tool's own range reaches where this fibre's fits go non-finite (#538), so the - search keeps to the run's window; a capillary keeps the tool's. - - - ``pump``: the pump's wavelength, m. - - Returns: the range, m; ``None`` for the tool's own. - """ - if recipe.keywords().get("lattice") is None: - return None - return _curve_range(recipe, pump)[0], 0.9 * pump - - -def _zero_near(wavelength: np.ndarray, beta2: np.ndarray, pump: float) -> float | None: - """Return the zero of a dispersion curve nearest a pump, read from the curve. - - - ``wavelength``: the curve's wavelengths, m, ascending. - - ``pump``: the pump's wavelength, m. - - Returns: the wavelength, m; ``None`` where the curve does not cross zero. - """ - crossings = np.flatnonzero(np.sign(beta2[:-1]) * np.sign(beta2[1:]) < 0) - zeros = [ - float( - wavelength[i] - + (wavelength[i + 1] - wavelength[i]) * beta2[i] / (beta2[i] - beta2[i + 1]) - ) - for i in crossings - ] - return min(zeros, key=lambda at: abs(at - pump), default=None) - - def _lattice_zero(recipe: Recipe, mode: object, pump: float) -> float: """Return the zero an antiresonant fibre's run can reach. @@ -272,45 +173,13 @@ def _lattice_zero(recipe: Recipe, mode: object, pump: float) -> float: - ``pump``: the pump's wavelength, m. - Returns: the wavelength, m; NaN where the window holds no zero. """ - low, high = _curve_range(recipe, pump) + low, high = curve_range(recipe, pump) wavelength = np.geomspace(low, high, CURVE_POINTS) beta2 = np.asarray(base.beta2(mode, 2.0 * math.pi * C / wavelength)) # pyright: ignore[reportArgumentType] - zero = _zero_near(wavelength, beta2, pump) + zero = zero_near(wavelength, beta2, pump) return math.nan if zero is None else zero -def _curve_range(recipe: Recipe, pump: float) -> tuple[float, float]: - """Return the wavelengths a dispersion curve covers: the run's own window. - - - ``pump``: the pump's wavelength, m. - - Returns: the two ends, m. - """ - keywords = recipe.keywords() - limits = keywords.get("wavelength_limits") - if isinstance(limits, tuple | list) and len(limits) == 2: - return float(limits[0]), float(limits[1]) # pyright: ignore[reportArgumentType] - grid = keywords.get("grid") - if isinstance(grid, EnvelopeGrid): - return 2.0 * math.pi * C / grid.omega_max, 2.0 * math.pi * C / grid.omega_min - return pump * _CURVE_WINDOW[0], pump * _CURVE_WINDOW[1] - - -def _reach_of(values: np.ndarray, wavelength: np.ndarray, pump: float) -> float: - """Return how far a dispersion curve's axis should reach from zero. - - The curve runs off near a resonance, and a fibre's resonance dwarfs the pump's - neighbourhood, so the axis follows what the eye reads there. - - - ``wavelength``: its wavelengths, m, ascending. - - ``pump``: the pump's wavelength, m. - """ - reach = float(np.nanmax(np.abs(values[wavelength > 0.25 * wavelength[-1]]))) - omega, centre = 1.0 / wavelength, 1.0 / pump - near = np.abs(omega - centre) < _NEAR_PUMP * omega - around = float(np.nanmax(np.abs(values[near]))) if near.any() else reach - return max(min(reach, _NEAR_REACH * around), 1.0) * 1.05 - - def _shapes_of(mode: object) -> list[dict[str, object]]: """Return a guide's cross-section as plain shapes, to scale. @@ -361,7 +230,7 @@ def design_numbers(recipe: Recipe, energy: float) -> dict[str, float]: """ params = capillary_numbers(recipe, energy) numbers = _as_numbers(params) - wave = _shortest_wave( + wave = shortest_wave( params.mode, params.wavelength, beta2=params.beta2, @@ -369,7 +238,7 @@ def design_numbers(recipe: Recipe, energy: float) -> dict[str, float]: gamma=params.gamma, fwhm=params.fwhm, shape=params.shape, - limits=_wave_window(recipe, params.wavelength), + limits=wave_window(recipe, params.wavelength), ) if wave is not None: numbers["rdw"] = wave @@ -391,7 +260,7 @@ def capillary_sheet(payload: Payload) -> dict[str, object]: curve's request reads back; ``pinned``, by pinned path, the solved value in its keyword's unit; and ``resolved``, the recipe's text with its pins solved. """ - authored = _recipe(payload) + authored = read_recipe(payload) recipe = authored.resolve() keywords = recipe.keywords() energy = keywords.get("energy") @@ -432,16 +301,16 @@ def capillary_curve(payload: Payload) -> dict[str, object]: ``None``) and the ``loss`` at the pump (dB/m). With a lattice, also ``loss`` (``x``, ``y`` in dB/m and ``pump``) and ``shapes``, its cross-section. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) numbers: Mapping[str, object] = payload["numbers"] # pyright: ignore[reportAssignmentType] mode = recipe.build().modes[0] pump = float(numbers["wavelength"]) # pyright: ignore[reportArgumentType] - low, high = _curve_range(recipe, pump) + low, high = curve_range(recipe, pump) wavelength = np.geomspace(low, high, CURVE_POINTS) omega = 2.0 * math.pi * C / wavelength beta2 = np.asarray(base.beta2(mode, omega)) * 1e30 loss = float(base.alpha(mode, np.array([2 * math.pi * C / pump]))[0]) - wave = _shortest_wave( + wave = shortest_wave( mode, pump, beta2=float(numbers["beta2"]), # pyright: ignore[reportArgumentType] @@ -449,10 +318,10 @@ def capillary_curve(payload: Payload) -> dict[str, object]: gamma=float(numbers["gamma"]), # pyright: ignore[reportArgumentType] fwhm=float(numbers["fwhm"]), # pyright: ignore[reportArgumentType] shape=str(numbers["shape"]), - limits=_wave_window(recipe, pump), + limits=wave_window(recipe, pump), ) nanometres = wavelength * 1e9 - top = _reach_of(beta2, wavelength, pump) + top = reach_of(beta2, wavelength, pump) zdw = float(numbers["zdw"]) # pyright: ignore[reportArgumentType] reply: dict[str, object] = { "plot": { @@ -500,7 +369,7 @@ def fibre_sheet(payload: Payload) -> dict[str, object]: no pins) and ``resolved``, the recipe's text. What the recipe does not give is left out. """ - recipe = _recipe(payload).resolve() + recipe = read_recipe(payload).resolve() keywords = recipe.keywords() mode = recipe.build().modes[0] pump = float(keywords["wavelength"]) # pyright: ignore[reportArgumentType] @@ -557,13 +426,13 @@ def fibre_curve(payload: Payload) -> dict[str, object]: ``zdw`` nearest the pump (m, or ``None``); a solved fibre's ``shapes``, its cross-section. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) numbers: Mapping[str, object] = payload["numbers"] # pyright: ignore[reportAssignmentType] pump = float(numbers["wavelength"]) # pyright: ignore[reportArgumentType] mode = recipe.build().modes[0] - wavelength = np.geomspace(*_curve_range(recipe, pump), CURVE_POINTS) + wavelength = np.geomspace(*curve_range(recipe, pump), CURVE_POINTS) beta2 = np.asarray(base.beta2(mode, 2.0 * math.pi * C / wavelength)) * 1e30 - zdw = _zero_near(wavelength, beta2, pump) + zdw = zero_near(wavelength, beta2, pump) reach = float(np.nanmax(np.abs(beta2))) or 1.0 reply: dict[str, object] = { "plot": { @@ -604,7 +473,7 @@ def beam_sheet(payload: Payload) -> dict[str, object]: text. A medium that is no gas has only its ``peak_power``, with ``refusal`` saying why. Of several beams launched together, the numbers are the first's. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) keywords = recipe.keywords() beam = keywords["initial"] medium = keywords["medium"] @@ -704,7 +573,7 @@ def lens_beam(payload: Payload) -> dict[str, float]: dict ``waist`` and ``distance``, m. """ - wavelength = _recipe(payload).keywords()["initial"].temporal.wavelength # pyright: ignore[reportAttributeAccessIssue] + wavelength = read_recipe(payload).keywords()["initial"].temporal.wavelength # pyright: ignore[reportAttributeAccessIssue] waist, distance = beam_through_lens( float(payload["focal_length"]), # pyright: ignore[reportArgumentType] float(payload["radius"]), # pyright: ignore[reportArgumentType] @@ -726,7 +595,7 @@ def output_size(payload: Payload) -> dict[str, object]: dict ``output_bytes``. """ - return {"output_bytes": _recipe(payload).resolve().build().output_bytes()} + return {"output_bytes": read_recipe(payload).resolve().build().output_bytes()} def gases(payload: Payload) -> list[str]: # noqa: ARG001 - none @@ -810,7 +679,7 @@ def grin_sheet(payload: Payload) -> dict[str, object]: ``mode_count``, the guided modes a power-law core holds, about V² p / (2 (p + 2)); ``pinned`` (empty) and ``resolved``, the recipe's text. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) metre, joule, second = Unit("m"), Unit("J"), Unit("s") pump = float(parse_quantity(recipe.get("wavelength"), metre, path="wavelength")) energy = float(parse_quantity(recipe.get("energy"), joule, path="energy")) @@ -849,7 +718,7 @@ def grin_curve(payload: Payload) -> dict[str, object]: :func:`capillary_curve` gives one; none where the indices are not plain numbers. ``numbers`` is empty. """ - profile = _graded(_recipe(payload)) + profile = _graded(read_recipe(payload)) if "axis" not in profile or "exterior" not in profile: return {"numbers": {}} radius = profile["radius"] diff --git a/src/solprop/service/waveguide.py b/src/solprop/service/waveguide.py index 71c0eef8..32797dd5 100644 --- a/src/solprop/service/waveguide.py +++ b/src/solprop/service/waveguide.py @@ -20,17 +20,18 @@ from solprop.exceptions import SolpropError from solprop.modes import base from solprop.recipe import Recipe -from solprop.service.design import _CHART_KEYWORDS -from solprop.service.explore import QUANTITIES, quantity_values -from solprop.service.sheet import ( +from solprop.service._curves import ( CURVE_POINTS, - _curve_range, - _reach_of, - _shortest_wave, - _wave_window, - _zero_near, - capillary_sheet, + curve_range, + reach_of, + shortest_wave, + wave_window, + zero_near, ) +from solprop.service._payload import Payload +from solprop.service.design import PRESSURE_ZDW_KEYWORDS +from solprop.service.explore import QUANTITIES, quantity_values +from solprop.service.sheet import capillary_sheet from solprop.tools import capillary_design, phase_mismatch, pressure_zdw __all__ = [ @@ -41,7 +42,6 @@ "waveguide_solve", ] -Payload = Mapping[str, object] _EXAMPLES: Final = {"capillary": "rdw", "antiresonant": "antiresonant_fibre"} """The example each guide starts from.""" @@ -100,7 +100,7 @@ def _curve_of( """ mode = recipe.build().modes[0] pump = float(recipe.keywords()["wavelength"]) # pyright: ignore[reportArgumentType] - wavelength = np.geomspace(*_curve_range(recipe, pump), CURVE_POINTS) + wavelength = np.geomspace(*curve_range(recipe, pump), CURVE_POINTS) omega = 2.0 * math.pi * C / wavelength quantity = _SOURCES[key] match quantity.source: @@ -184,7 +184,7 @@ def waveguide_curves(payload: Payload) -> dict[str, object]: continue x = wavelength * 1e9 pump = float(recipe.keywords()["wavelength"]) * 1e9 # pyright: ignore[reportArgumentType] - zero = _zero_near(wavelength, dispersion, pump * 1e-9) + zero = zero_near(wavelength, dispersion, pump * 1e-9) curves.append( { "value": value, @@ -195,7 +195,7 @@ def waveguide_curves(payload: Payload) -> dict[str, object]: limit = None drawn = [np.asarray(curve["y"], dtype=float) for curve in curves if "y" in curve] if drawn and key in ("beta2", "dispersion", "beta3", "mismatch"): - limit = _reach_of(np.max(np.abs(drawn), axis=0), x * 1e-9, pump * 1e-9) + limit = reach_of(np.max(np.abs(drawn), axis=0), x * 1e-9, pump * 1e-9) return {"x": x, "pump": pump, "key": key, "limit": limit, "curves": curves} @@ -242,7 +242,7 @@ def waveguide_numbers(payload: Payload) -> dict[str, object]: "fission_length", ): reply[key] = sheet[key] - reply["rdw"] = _shortest_wave( + reply["rdw"] = shortest_wave( mode, pump, beta2=float(sheet["beta2"]), # pyright: ignore[reportArgumentType] @@ -250,7 +250,7 @@ def waveguide_numbers(payload: Payload) -> dict[str, object]: gamma=float(sheet["gamma"]), # pyright: ignore[reportArgumentType] fwhm=float(sheet["fwhm"]), # pyright: ignore[reportArgumentType] shape=str(sheet["shape"]), - limits=_wave_window(recipe, pump), + limits=wave_window(recipe, pump), ) return reply @@ -274,7 +274,7 @@ def waveguide_solve(payload: Payload) -> dict[str, object]: gas: object = keywords["gas"] fill = { name: keywords[name] - for name in _CHART_KEYWORDS + for name in PRESSURE_ZDW_KEYWORDS if keywords.get(name) is not None } order = payload.get("soliton_order") diff --git a/tests/service/test_scan_reductions.py b/tests/service/test_scan_reductions.py index 0cc04ca8..b5cf107f 100644 --- a/tests/service/test_scan_reductions.py +++ b/tests/service/test_scan_reductions.py @@ -16,7 +16,7 @@ from solprop.driver.scan import run_scan from solprop.recipe import Recipe from solprop.service import results -from solprop.service.run import _plain +from solprop.service.run import _point_value from solprop.service.scan_reductions import ( AXIS_POINTS, reduction_axis, @@ -114,7 +114,7 @@ def test_a_range_is_written_as_numbers_and_reductions_reach_the_worker( collector.collect(until="point", then="stop") started = next(e.data for e in collector.events if e.kind == "scan_started") assert started["values"] == [["5e-05", "0.0001"]] - assert _plain("x") == "x" + assert _point_value("x") == "x" def test_a_beam_s_scan_is_refused_the_readers_of_a_guided_run() -> None: diff --git a/tests/service/test_sheet.py b/tests/service/test_sheet.py index ecd5ab80..d3fd0ce0 100644 --- a/tests/service/test_sheet.py +++ b/tests/service/test_sheet.py @@ -13,11 +13,10 @@ from service.cases import TINY from solprop import tools from solprop.recipe import Recipe +from solprop.service._curves import CURVE_POINTS, curve_range from solprop.service.handlers import HANDLERS from solprop.service.messages import PROTOCOL from solprop.service.sheet import ( - CURVE_POINTS, - _curve_range, _shapes_of, beam_curve, beam_sheet, @@ -106,7 +105,7 @@ def test_a_window_absent_from_the_recipe_is_around_the_pump() -> None: def test_an_anomalous_pump_with_no_phase_match_has_no_wave( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr("solprop.service.sheet.lambda_rdw_all", lambda *_a, **_k: ()) + monkeypatch.setattr("solprop.service._curves.lambda_rdw_all", lambda *_a, **_k: ()) assert "rdw" not in design_numbers(Recipe.loads(TINY), 100e-6) @@ -305,7 +304,7 @@ def test_a_curve_with_no_window_reaches_around_the_pump( ) -> None: recipe = Recipe.loads(TINY) monkeypatch.setattr(Recipe, "keywords", lambda self: {}) - assert _curve_range(recipe, 800e-9) == pytest.approx((120e-9, 1600e-9)) + assert curve_range(recipe, 800e-9) == pytest.approx((120e-9, 1600e-9)) def test_the_run_options_are_solprop_s() -> None: From 7927469d831d507bd2366b0c565a01552376a9c9 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:07:19 +0000 Subject: [PATCH 18/42] Trim process_stamp's private docstrings to the private form Co-Authored-By: Claude Opus 5.5 --- src/solprop/_process.py | 44 ++++++----------------------------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/src/solprop/_process.py b/src/solprop/_process.py index ea6d68f8..acfe6b88 100644 --- a/src/solprop/_process.py +++ b/src/solprop/_process.py @@ -713,16 +713,8 @@ def process_stamp(pid: int) -> str | None: A process number is reused once its process has gone; the start time tells a run's own process from a later one with its number. - Parameters - ---------- - pid : int - The process. - - Returns - ------- - str or None - Its start, in the system's own terms; ``None`` where the system does not say - or the process does not exist. + - Returns: its start, in the system's own terms; ``None`` where the system does not + say or the process does not exist. """ if sys.platform == "linux": return _proc_stamp(pid) @@ -734,16 +726,8 @@ def process_stamp(pid: int) -> str | None: def _proc_stamp(pid: int) -> str | None: """Return a process's start as Linux's ``/proc`` gives it, in clock ticks. - Parameters - ---------- - pid : int - The process. - - Returns - ------- - str or None - Field 22 of ``/proc//stat``; ``None`` when the process does not exist, - including when it exits while the file is read. + - Returns: field 22 of ``/proc//stat``; ``None`` when the process does not + exist, including when it exits while the file is read. """ try: stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") @@ -757,15 +741,7 @@ def _proc_stamp(pid: int) -> str | None: def _ps_stamp(pid: int) -> str | None: # pragma: no cover - not Linux, see above """Return a process's start as ``ps`` gives it, off Linux. - Parameters - ---------- - pid : int - The process. - - Returns - ------- - str or None - Its start; ``None`` when it does not exist. + - Returns: its start; ``None`` when it does not exist. """ import subprocess # noqa: PLC0415 - only off Linux @@ -781,15 +757,7 @@ def _ps_stamp(pid: int) -> str | None: # pragma: no cover - not Linux, see abov def _windows_stamp(pid: int) -> str | None: # pragma: no cover - Windows only """Return a Windows process's creation time, or ``None`` when it has gone. - Parameters - ---------- - pid : int - The process. - - Returns - ------- - str or None - Its creation time in 100 ns units, as text. + - Returns: its creation time in 100 ns units, as text. """ import ctypes # noqa: PLC0415 - Windows only from ctypes import wintypes # noqa: PLC0415 - Windows only From 5302ca1e1e34c621aa4af823c64d37c4103f9866 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:10:38 +0000 Subject: [PATCH 19/42] Let a Marcatili subclass declare that dneff_dz does not apply Replaces the type(self) identity check with a class attribute the structured-cladding and tabulated modes override. Co-Authored-By: Claude Opus 5.5 --- src/solprop/modes/antiresonant.py | 3 ++- src/solprop/modes/capillary.py | 8 +++++++- src/solprop/modes/tabulated.py | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/solprop/modes/antiresonant.py b/src/solprop/modes/antiresonant.py index a8491e23..659a94c4 100644 --- a/src/solprop/modes/antiresonant.py +++ b/src/solprop/modes/antiresonant.py @@ -18,7 +18,7 @@ import logging import math from dataclasses import dataclass, replace -from typing import Final +from typing import ClassVar, Final import numpy as np from jaxtyping import Float, Inexact @@ -668,6 +668,7 @@ class VincettiMode(MarcatiliMode): """ lattice: TubeLattice + _INDEX_IS_MARCATILI: ClassVar[bool] = False def _check_arguments(self) -> None: """Validate the parent inputs and the tube geometry.""" diff --git a/src/solprop/modes/capillary.py b/src/solprop/modes/capillary.py index 768fff41..1515687f 100644 --- a/src/solprop/modes/capillary.py +++ b/src/solprop/modes/capillary.py @@ -370,6 +370,12 @@ class MarcatiliMode(GasFillMixin): cladding_index: GlassIndex | complex = field(init=False, repr=False) _aeff_factor: float = field(init=False, repr=False) _gain_warned: bool = field(init=False, default=False, repr=False) + _INDEX_IS_MARCATILI: ClassVar[bool] = True + """Whether the index is the Marcatili one that :meth:`dneff_dz` differentiates. + + A subclass that computes its index another way -- a structured cladding, a + tabulated index -- sets it ``False``. + """ # The mode a frozen copy was taken from, when the copy's frequency-only terms are # the origin's (:meth:`at_parameters`); shared by :meth:`sampled_terms` in a scope. _terms_origin: ClassVar["MarcatiliMode | None"] = None @@ -1193,7 +1199,7 @@ def dneff_dz( >>> bool(mode.dneff_dz(np.array([2.356e15]), 5e-4)[0].real < 0.0) True """ - if type(self) is not MarcatiliMode: + if not self._INDEX_IS_MARCATILI: msg = ( f"dneff_dz differentiates the Marcatili index, and " f"{type(self).__name__} computes its index another way; it declares " diff --git a/src/solprop/modes/tabulated.py b/src/solprop/modes/tabulated.py index bb39a895..d3eafd1f 100644 --- a/src/solprop/modes/tabulated.py +++ b/src/solprop/modes/tabulated.py @@ -12,7 +12,7 @@ import logging from collections.abc import Mapping from dataclasses import dataclass, field, replace -from typing import Literal +from typing import ClassVar, Literal import numpy as np from jaxtyping import Complex, Float, Inexact @@ -496,6 +496,7 @@ class _TableMode(MarcatiliMode): table: IndexTable reference_wavelength: float | None = None + _INDEX_IS_MARCATILI: ClassVar[bool] = False def _check_arguments(self) -> None: """Refuse geometry or area declarations incompatible with a fixed table.""" From 4987aab0dce6f96de72cda2f9b35041f27741ddf Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:10:38 +0000 Subject: [PATCH 20/42] Fold the rectangular and slab modes' shared members into their base Co-Authored-By: Claude Opus 5.5 --- src/solprop/modes/rectangular.py | 266 +++++++++++++++---------------- src/solprop/modes/slab.py | 149 ++--------------- tests/modes/test_rectangular.py | 6 +- tests/modes/test_slab.py | 8 +- 4 files changed, 156 insertions(+), 273 deletions(-) diff --git a/src/solprop/modes/rectangular.py b/src/solprop/modes/rectangular.py index 00b14da4..98c29a4e 100644 --- a/src/solprop/modes/rectangular.py +++ b/src/solprop/modes/rectangular.py @@ -24,7 +24,7 @@ from collections.abc import Mapping, Sequence from dataclasses import KW_ONLY, dataclass, field, replace from types import MappingProxyType -from typing import Literal, cast +from typing import ClassVar, Literal, cast import numpy as np from jaxtyping import Complex, Float, Inexact @@ -33,7 +33,12 @@ from solprop.constants import EPS0, C from solprop.exceptions import GrazingIncidenceWarning, ModeError from solprop.modes.fill import GasFillMixin, LossSpec, _sqrt1p_minus_one -from solprop.modes.protocol import ChannelLabel, CrossSection, PolarisationComponent +from solprop.modes.protocol import ( + ChannelLabel, + CrossSection, + ParameterisedMode, + PolarisationComponent, +) from solprop.physdata.gases import Gas from solprop.physdata.glasses import GlassIndex, fused_silica from solprop.physdata.mixtures import Mixture @@ -111,8 +116,40 @@ class _WallPairFill(GasFillMixin): concrete frozen dataclass declares ``cladding_index`` beside the fill fields. """ + cladding: GlassIndex | complex | None cladding_index: GlassIndex | complex _validity_warned: list[bool] + _MODEL: ClassVar[str] + """The model's name in a refusal: ``"rectangular"`` or ``"the slab"``.""" + + def _walls(self, z: float) -> tuple[tuple[float, int, Literal["te", "tm"]], ...]: + """Return each wall pair's half-width in m, mode order and reflection at z.""" + raise NotImplementedError + + def _resolve_walls(self, *geometry: ZProfile) -> None: + """Resolve the fill and the wall material, and check the geometry at its kinks. + + The second half of a concrete mode's ``__post_init__``, after its own fields + and its geometry paths are set. The once-per-mode validity flag is an + attribute rather than a field, so the declared fields stay what they were, + and a list so that the shallow copies ``at_parameters`` makes share it + (:meth:`_warn_validity`). + """ + object.__setattr__(self, "_validity_warned", [False]) + self._resolve_fill() + object.__setattr__( + self, + "cladding_index", + fused_silica() if self.cladding is None else self.cladding, + ) + for path in geometry: + positions = np.unique( + np.concatenate((path.breakpoints, np.asarray(path.z_span), [0.0])) + ) + for position in positions: + if math.isfinite(float(position)): + self._walls(float(position)) + self._warn_loss_derivative() def _warn_validity( self, terms: _RectTerms, pairs: Sequence[tuple[float, int, Literal["te", "tm"]]] @@ -250,6 +287,87 @@ def _core_permittivity( """ return 1.0 + terms.chi, terms.dchi + def _model_values( + self, + terms: _RectTerms, + walls: tuple[tuple[float, int, Literal["te", "tm"]], ...], + ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: + """Evaluate the reflection model before the shared loss policy. + + - ``walls``: what :meth:`_walls` returns. + - Returns: refractivity and its frequency derivative in s/rad. + """ + te, dte, tm, dtm = self._wall_coefficients(terms) + argument, dargument = terms.chi, terms.dchi + for half_width, order, reflection in walls: + q, dq = (te, dte) if reflection == "te" else (tm, dtm) + decrement, ddecrement = _transverse_term( + terms.omega, half_width, order, q, dq + ) + argument = argument - decrement + dargument = dargument - ddecrement + # The rationalised sqrt(1 + argument) - 1 the Marcatili model shares: it + # keeps every digit in the large-guide limit, where the argument is tiny. + refractivity = _sqrt1p_minus_one(argument) + derivative = dargument / (2 * (1 + refractivity)) + if not (np.all(np.isfinite(refractivity)) and np.all(np.isfinite(derivative))): + raise ModeError(f"{self._MODEL} model produced non-finite values") + return np.asarray(refractivity), np.asarray(derivative) + + def _values( + self, omega: Float[np.ndarray, " n_omega"], z: float + ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: + """Apply the shared loss policy and floor to the local model. + + - ``omega``: positive frequencies in rad/s. + - ``z``: position in m. + - Returns: refractivity and its derivative in s/rad after loss and floor + policies. + """ + walls = self._walls(z) + terms = self._terms(omega, z) + values, derivative = self._model_values(terms, walls) + self._warn_validity(terms, walls) + return self._floored(*self._apply_loss(omega, values, derivative)) + + @property + def z_constant(self) -> bool: + """Whether the geometry and every material path are constant.""" + return all( + path.is_constant + for path in cast("ParameterisedMode", self).parameter_paths.values() + ) + + @property + def polarisation_degenerate(self) -> bool: + """False: the two polarisations take different wall coefficients. + + So they are distinct modes, each with its own operator row. + """ + return False + + def neff( + self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 + ) -> Complex[np.ndarray, " n_omega"]: + """Return the effective index, positive imaginary part for attenuation.""" + return 1 + self._values(omega, z)[0] + + def neff_minus_one( + self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 + ) -> Complex[np.ndarray, " n_omega"]: + """Return the refractivity directly from the small square-root argument.""" + return self._values(omega, z)[0] + + def dneff_domega( + self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 + ) -> Complex[np.ndarray, " n_omega"]: + """Return the analytic derivative, with the core and wall material slopes. + + In s/rad; only a slope-free loss callable takes the shared numerical loss + derivative. + """ + return self._values(omega, z)[1] + @dataclass(frozen=True, eq=False) class RectMode(_WallPairFill): @@ -326,6 +444,7 @@ class RectMode(_WallPairFill): cladding_index: GlassIndex | complex = field(init=False, repr=False) model: Literal["full", "reduced"] = field(init=False, default="full", repr=False) _gain_warned: bool = field(init=False, default=False, repr=False) + _MODEL: ClassVar[str] = "rectangular" def __post_init__(self) -> None: """Validate the mode and resolve geometry and material paths. @@ -335,10 +454,6 @@ def __post_init__(self) -> None: ModeError If half-widths, indices, polarisation or fill declarations are invalid. """ - # The once-per-mode validity flag: an attribute rather than a field, so the - # declared fields stay what they were, and a list so that the shallow copies - # `at_parameters` makes share it (`_WallPairFill._warn_validity`). - object.__setattr__(self, "_validity_warned", [False]) if self.n < 1 or self.m < 1: raise ModeError("rectangular mode indices n and m must be positive") if self.polarisation not in ("x", "y"): @@ -346,20 +461,7 @@ def __post_init__(self) -> None: self._check_fill_arguments() object.__setattr__(self, "width_x", as_profile(self.half_width_x)) object.__setattr__(self, "width_y", as_profile(self.half_width_y)) - self._resolve_fill() - object.__setattr__( - self, - "cladding_index", - fused_silica() if self.cladding is None else self.cladding, - ) - for path in (self.width_x, self.width_y): - positions = np.unique( - np.concatenate((path.breakpoints, np.asarray(path.z_span), [0.0])) - ) - for position in positions: - if math.isfinite(float(position)): - self._widths(float(position)) - self._warn_loss_derivative() + self._resolve_walls(self.width_x, self.width_y) def _widths(self, z: float) -> tuple[float, float]: """Return the positive half-widths at one longitudinal position. @@ -373,6 +475,16 @@ def _widths(self, z: float) -> tuple[float, float]: raise ModeError("rectangular half-widths must be positive and finite") return a, b + def _walls(self, z: float) -> tuple[tuple[float, int, Literal["te", "tm"]], ...]: + """Return the x and y wall pairs at z. + + The x walls see the TM-like reflection when E is along x, normal to them. + """ + a, b = self._widths(z) + x_wall: Literal["te", "tm"] = "tm" if self.polarisation == "x" else "te" + y_wall: Literal["te", "tm"] = "te" if x_wall == "tm" else "tm" + return (a, self.m, x_wall), (b, self.n, y_wall) + @property def parameter_paths(self) -> Mapping[str, ZProfile]: # numpydoc ignore=RT01 """Geometry and material paths carrying longitudinal variation. @@ -422,11 +534,6 @@ def at_parameters(self, values: Mapping[str, float]) -> "RectMode": frozen._widths(0.0) return frozen - @property - def z_constant(self) -> bool: # numpydoc ignore=RT01 - """Whether every geometry and material path is constant.""" - return all(path.is_constant for path in self.parameter_paths.values()) - @property def radius_is_constant(self) -> bool: # numpydoc ignore=RT01 """The transform's geometry-constant trait for the rectangle. @@ -444,16 +551,6 @@ def scale_invariant(self) -> bool: # numpydoc ignore=RT01 """ return self.radius_is_constant or self.width_x is self.width_y - @property - def polarisation_degenerate(self) -> bool: # numpydoc ignore=RT01 - """Whether the two polarisations share one effective index. - - False: the x- and y-polarised modes of one (n, m) take the TE-like - and TM-like coefficients on opposite wall pairs, so they are - distinct modes and need a row each. - """ - return False - @property def azimuthal_order(self) -> int: # numpydoc ignore=RT01 """Larger Cartesian order as a geometry-resolution hint. @@ -514,105 +611,6 @@ def orthogonal_polarisation(self) -> "RectMode": # numpydoc ignore=RT01 """ return replace(self, polarisation="y" if self.polarisation == "x" else "x") - def _model_values( - self, terms: _RectTerms, a: float, b: float - ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: - """Evaluate the complex reflection model before the shared loss policy. - - - ``a``, ``b``: positive half-widths in m. - - Returns: refractivity and its frequency derivative in s/rad. - """ - te, dte, tm, dtm = self._wall_coefficients(terms) - x, dx = (tm, dtm) if self.polarisation == "x" else (te, dte) - y, dy = (te, dte) if self.polarisation == "x" else (tm, dtm) - first, dfirst = _transverse_term(terms.omega, a, self.m, x, dx) - second, dsecond = _transverse_term(terms.omega, b, self.n, y, dy) - argument = terms.chi - first - second - # The rationalised sqrt(1 + argument) - 1 the Marcatili model shares: it - # keeps every digit in the large-guide limit, where the argument is tiny. - refractivity = _sqrt1p_minus_one(argument) - derivative = (terms.dchi - dfirst - dsecond) / (2 * (1 + refractivity)) - if not (np.all(np.isfinite(refractivity)) and np.all(np.isfinite(derivative))): - raise ModeError("rectangular model produced non-finite values") - return np.asarray(refractivity), np.asarray(derivative) - - def _values( - self, omega: Float[np.ndarray, " n_omega"], z: float - ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: - """Apply the shared loss policy and floor to the local model. - - - ``omega``: positive frequencies in rad/s. - - ``z``: position in m. - - Returns: refractivity and its derivative in s/rad after loss and floor - policies. - """ - a, b = self._widths(z) - terms = self._terms(omega, z) - values, derivative = self._model_values(terms, a, b) - # The x walls see the TM-like reflection when E is along x, normal to them. - x_wall: Literal["te", "tm"] = "tm" if self.polarisation == "x" else "te" - y_wall: Literal["te", "tm"] = "te" if x_wall == "tm" else "tm" - self._warn_validity(terms, ((a, self.m, x_wall), (b, self.n, y_wall))) - return self._floored(*self._apply_loss(omega, values, derivative)) - - def neff( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the effective index with positive imaginary part for attenuation. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex effective index, shape (n_omega,). - """ - return 1 + self._values(omega, z)[0] - - def neff_minus_one( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return refractivity directly from the small square-root argument. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex n_eff - 1, without subtracting unity from the returned index. - """ - return self._values(omega, z)[0] - - def dneff_domega( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the analytic derivative including core and wall material slopes. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex index derivative in s/rad; only a slope-free loss callable - uses the shared numerical loss derivative. - """ - return self._values(omega, z)[1] - def aeff(self, z: float = 0.0) -> float: """Return the closed-form effective area. diff --git a/src/solprop/modes/slab.py b/src/solprop/modes/slab.py index f2d0f13b..337bdca1 100644 --- a/src/solprop/modes/slab.py +++ b/src/solprop/modes/slab.py @@ -53,7 +53,7 @@ from collections.abc import Mapping, Sequence from dataclasses import KW_ONLY, dataclass, field, replace from types import MappingProxyType -from typing import Final, Literal, cast +from typing import ClassVar, Final, Literal, cast import numpy as np from jaxtyping import Complex, Float @@ -62,7 +62,7 @@ from solprop.constants import EPS0, C from solprop.exceptions import GridError, ModeError from solprop.grid import _check_fraction, _periodic_axis -from solprop.modes.fill import LossSpec, _sqrt1p_minus_one +from solprop.modes.fill import LossSpec from solprop.modes.protocol import ( ChannelLabel, CrossSection, @@ -78,9 +78,9 @@ quadrature_rule, transverse_quadrature, ) -from solprop.modes.rectangular import _RectTerms, _transverse_term, _WallPairFill +from solprop.modes.rectangular import _WallPairFill from solprop.physdata.gases import Gas -from solprop.physdata.glasses import GlassIndex, fused_silica +from solprop.physdata.glasses import GlassIndex from solprop.physdata.mixtures import Mixture from solprop.physdata.sellmeier import GasSusceptibility from solprop.profiles import Constant, ZProfile, _temperature_text, as_profile @@ -198,6 +198,7 @@ class SlabMode(_WallPairFill): cladding_index: GlassIndex | complex = field(init=False, repr=False) model: Literal["full", "reduced"] = field(init=False, default="full", repr=False) _gain_warned: bool = field(init=False, default=False, repr=False) + _MODEL: ClassVar[str] = "the slab" def __post_init__(self) -> None: """Validate the mode and resolve the gap and material paths. @@ -208,10 +209,6 @@ def __post_init__(self) -> None: If the order, the polarisation, the gap or the fill declaration is invalid. """ - # The once-per-mode validity flag: an attribute rather than a field, so the - # declared fields stay what they were, and a list so that the shallow copies - # `at_parameters` makes share it (`_WallPairFill._warn_validity`). - object.__setattr__(self, "_validity_warned", [False]) # bool is an int in Python; True is not a mode order. if ( not isinstance(self.order, int) @@ -229,21 +226,7 @@ def __post_init__(self) -> None: ) self._check_fill_arguments() object.__setattr__(self, "width_x", as_profile(self.half_gap)) - self._resolve_fill() - object.__setattr__( - self, - "cladding_index", - fused_silica() if self.cladding is None else self.cladding, - ) - positions = np.unique( - np.concatenate( - (self.width_x.breakpoints, np.asarray(self.width_x.z_span), [0.0]) - ) - ) - for position in positions: - if math.isfinite(float(position)): - self._half_gap(float(position)) - self._warn_loss_derivative() + self._resolve_walls(self.width_x) def _half_gap(self, z: float) -> float: """Return the positive half-gap at one longitudinal position. @@ -258,6 +241,15 @@ def _half_gap(self, z: float) -> float: ) return a + def _walls(self, z: float) -> tuple[tuple[float, int, Literal["te", "tm"]], ...]: + """Return the one wall pair at z. + + E parallel to the plates is the TE-like (s) reflection, E normal to them the + TM-like (p) one: the rectangular guide's y-polarised mode sees te on its x + walls, which is this TE mode in the limit b -> infinity. + """ + return ((self._half_gap(z), self.order, self.polarisation),) + @property def transverse_measure(self) -> TransverseMeasure: # numpydoc ignore=RT01 """What this mode's transverse integrals measure. @@ -309,11 +301,6 @@ def at_parameters(self, values: Mapping[str, float]) -> "SlabMode": frozen._half_gap(0.0) return frozen - @property - def z_constant(self) -> bool: # numpydoc ignore=RT01 - """Whether the gap and every material path are constant.""" - return all(path.is_constant for path in self.parameter_paths.values()) - @property def radius_is_constant(self) -> bool: # numpydoc ignore=RT01 """The transform's geometry-constant trait for the slab. @@ -333,15 +320,6 @@ def scale_invariant(self) -> bool: # numpydoc ignore=RT01 """ return True - @property - def polarisation_degenerate(self) -> bool: # numpydoc ignore=RT01 - """Whether the two polarisations share one effective index. - - False: TE and TM take different wall coefficients, so they are distinct - modes and need a row each, as the rectangular partners do. - """ - return False - @property def azimuthal_order(self) -> int: # numpydoc ignore=RT01 """Mode order as the rule's resolution hint. @@ -396,103 +374,6 @@ def orthogonal_polarisation(self) -> "SlabMode": # numpydoc ignore=RT01 """ return replace(self, polarisation="tm" if self.polarisation == "te" else "te") - def _model_values( - self, terms: _RectTerms, a: float - ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: - """Evaluate the one-wall-pair reflection model before the loss policy. - - - ``a``: positive half-gap in m. - - Returns: refractivity and its frequency derivative in s/rad. - """ - te, dte, tm, dtm = self._wall_coefficients(terms) - # E parallel to the plates is the TE-like (s) reflection, E normal to them - # the TM-like (p) one: the rectangular guide's y-polarised mode sees te on - # its x walls, which is this TE mode in the limit b -> infinity. - q, dq = (te, dte) if self.polarisation == "te" else (tm, dtm) - decrement, ddecrement = _transverse_term(terms.omega, a, self.order, q, dq) - argument = terms.chi - decrement - # The rationalised sqrt(1 + argument) - 1, as the rectangular guide forms - # it, for the large-gap limit where the refractivity is tiny. - refractivity = _sqrt1p_minus_one(argument) - derivative = (terms.dchi - ddecrement) / (2 * (1 + refractivity)) - if not (np.all(np.isfinite(refractivity)) and np.all(np.isfinite(derivative))): - raise ModeError("the slab model produced non-finite values") - return np.asarray(refractivity), np.asarray(derivative) - - def _values( - self, omega: Float[np.ndarray, " n_omega"], z: float - ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: - """Apply the shared loss policy and floor to the local model. - - - ``omega``: positive frequencies in rad/s. - - ``z``: position in m. - - Returns: refractivity and its derivative in s/rad after loss and floor - policies. - """ - a = self._half_gap(z) - terms = self._terms(omega, z) - values, derivative = self._model_values(terms, a) - self._warn_validity(terms, ((a, self.order, self.polarisation),)) - return self._floored(*self._apply_loss(omega, values, derivative)) - - def neff( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the effective index with positive imaginary part for attenuation. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex effective index, shape (n_omega,). - """ - return 1 + self._values(omega, z)[0] - - def neff_minus_one( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the refractivity directly from the small square-root argument. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex ``n_eff - 1``, without subtracting unity from the index. - """ - return self._values(omega, z)[0] - - def dneff_domega( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the analytic derivative including core and wall material slopes. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex index derivative in s/rad; only a slope-free loss callable uses - the shared numerical loss derivative. - """ - return self._values(omega, z)[1] - def aeff(self, z: float = 0.0) -> float: """Return the closed-form effective width, per unit width in y. diff --git a/tests/modes/test_rectangular.py b/tests/modes/test_rectangular.py index 2e240b34..8598702f 100644 --- a/tests/modes/test_rectangular.py +++ b/tests/modes/test_rectangular.py @@ -91,7 +91,7 @@ def test_rectangular_contour_derivative_and_material_chain(polarisation): for phase in np.arange(64) * 2 * np.pi / 64: offset = 0.1 * omega * np.exp(1j * phase) value = mode._model_values( - replace(terms, omega=omega + offset), 50e-6, 12.5e-6 + replace(terms, omega=omega + offset), mode._walls(0.0) )[0] total = total + value / offset # Existing capillary contour tolerance: float rounding amplified by the @@ -265,7 +265,7 @@ def test_rectangular_remaining_validation_and_mixture_paths(): terms = mode._terms(np.asarray([2e15]), 0.0) with pytest.raises(ModeError, match="non-finite"): mode._model_values( - replace(terms, dwall=np.asarray([complex(np.nan)])), 50e-6, 12.5e-6 + replace(terms, dwall=np.asarray([complex(np.nan)])), mode._walls(0.0) ) mixture = Mixture.fill({"He": 0.25, "Ar": 0.75}, 2.0) filled = mode.with_mixture(mixture) @@ -294,7 +294,7 @@ def test_rectangular_luna_witness_at_matched_material_indices(): mode._terms(omega, 0.0), chi=np.asarray([complex(*case["core"]) ** 2 - 1]), ) - value = 1 + mode._model_values(terms, 50e-6, 12.5e-6)[0] + value = 1 + mode._model_values(terms, mode._walls(0.0))[0] # Identical material samples isolate the algebra from gas and metal data; # expm1/log1p versus sqrt differs by a few rounded complex operations. assert_allclose(value, complex(*case["neff"]), rtol=1e-15, atol=0) diff --git a/tests/modes/test_slab.py b/tests/modes/test_slab.py index b8dcbac8..ffee8bb1 100644 --- a/tests/modes/test_slab.py +++ b/tests/modes/test_slab.py @@ -288,7 +288,9 @@ def test_slab_derivative_by_contour_and_finite_difference(polarisation): total = np.zeros_like(omega, dtype=complex) for phase in np.arange(64) * 2 * np.pi / 64: offset = 0.1 * omega * np.exp(1j * phase) - value = mode._model_values(replace(terms, omega=omega + offset), 50e-6)[0] + value = mode._model_values( + replace(terms, omega=omega + offset), mode._walls(0.0) + )[0] total = total + value / offset # RectMode's contour tolerance: rounding amplified by the Cauchy radius, with # exponentially small 64-node trapezoid truncation. @@ -628,7 +630,9 @@ def test_slab_quadrature_and_model_refusals(): SlabMode(50e-6, cladding=1.0).neff(OMEGA_800) terms = mode._terms(OMEGA_800, 0.0) with np.errstate(invalid="ignore"), pytest.raises(ModeError, match="non-finite"): - mode._model_values(replace(terms, chi=np.asarray([np.nan + 0j])), 50e-6) + mode._model_values( + replace(terms, chi=np.asarray([np.nan + 0j])), mode._walls(0.0) + ) loss = replace(mode, loss=False) assert np.imag(loss.neff(OMEGA_800))[0] == 0.0 From ffae3635b5ad51d2f15c75f5a7c9d06820a5512c Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:11:50 +0000 Subject: [PATCH 21/42] Describe each statistic once, in its StatDefinition The parallel name tables of stats.py (general-basis admission, advice, the field, metric and window evaluators, the weight families, profiles, plasma families, apertures) become fields of StatDefinition, and the public sets are derived from the catalogue with unchanged values. The dead implemented flag and its refusal go. Co-Authored-By: Claude Opus 5.5 --- src/solprop/stats.py | 344 ++++++++++++++++++++++++------------------- tests/test_stats.py | 19 +-- 2 files changed, 194 insertions(+), 169 deletions(-) diff --git a/src/solprop/stats.py b/src/solprop/stats.py index 7ce8b00c..6a1f59ae 100644 --- a/src/solprop/stats.py +++ b/src/solprop/stats.py @@ -209,43 +209,6 @@ def species_statistics( } -PROFILE_STATISTICS = ("density", "pressure", "core_radius") -"""Statistics that read a :class:`solprop.profiles.ZProfile` at the plane's position.""" - -ON_AXIS_PLASMA_STATISTICS = ( - "on_axis_electron_density", - "on_axis_peak_ionisation_rate", - "on_axis_species_electron_density", - "on_axis_species_peak_ionisation_rate", -) -"""The plasma statistics evaluated on the waveguide's axis (SEP 19, section 5.2). - -Opt-in and saved-plane only. The axis field is synthesised from the mode profiles, as -for ``peak_intensity``, and its populations are scanned with exactly the rate tables, -preionisation, densities and running-integral rule of the node scan, once per -observation; a rule with a node on the axis supplies that row instead. They sit beside -the unchanged node reductions -- ``electron_density`` is the densest node's -- because a -few-node field-scale rule may have no node near the axis, and a multimode field's -largest density need not lie on it. Defined on resolved modal routes; the spatial -routes, general modal guides, the mode-averaged route and TDSE refuse them by name. -""" - -PLASMA_INTEGRATION_STATISTICS = ( - "plasma_undershoot", - "plasma_work_defect", - "plasma_boundary_level", -) -"""The field plasma column's run-time integration diagnostics (SEP 18, section 4.5). - -Evaluated with the other plasma statistics at each save plane. Each is a ratio of two -maxima over every sample, node and population, so that a node the field barely -ionises cannot set it: the undershoot ``max(-dS) / max(dS)`` of the exposure -increments; the work defect ``max |paid - freed| / max freed``, the loss current's work -per ionisation potential against the fraction it freed; and the boundary level, the -free-current integrand's largest magnitude in the ``2K`` samples the filter reads past -the window's start against its largest anywhere. -""" - DEFAULT_ZDW_RANGE = (100e-9, 3e-6) """Search window of :func:`zdw` when no narrower one is given, m.""" @@ -1212,9 +1175,6 @@ class StatDefinition: tables ``'apertures'`` and ``'regions'`` of the spec, or ``'separable_operator'``, the run's linear operator, which only a backend holds. - implemented : bool - Whether the statistic exists yet. A reserved name is in the catalogue so that a - spec asking for it is refused now instead of silently returning nothing. backend : bool Whether only a backend can compute it. These are the statistics of quantities the saved field does not carry -- the electron density, the ionisation rate, the @@ -1229,6 +1189,29 @@ class StatDefinition: would cost a large share of the step; ``peak_intensity`` is in the subset because its on-axis matrix is built once and rescaled rather than rebuilt. :class:`StatsSpec` refuses a periodic name without it, by name. + evaluate : collections.abc.Callable or None + The function :func:`collect` forms it with from a saved plane's field: called + ``(grid, field)``, with the spec's windows when it requires them, or with + ``metric=`` when it is metric-weighted; ``None`` for one formed otherwise. + metric : bool + Whether its value depends on the basis's flux metric + (:data:`METRIC_WEIGHTED_STATISTICS`). + weight : {'poynting', 'unit'} or None + How it depends on the modal power weight: ``'poynting'`` for one a backend + forms in its weighted Poynting pass, ``'unit'`` for a general modal statistic + valid only under the constant weight (:func:`weight_refusal_for`). + profile : bool + Whether it reads the z-profile of its own name (:data:`PROFILE_STATISTICS`). + plasma : {'on_axis', 'integration'} or None + Its family of plasma statistics (:data:`ON_AXIS_PLASMA_STATISTICS`, + :data:`PLASMA_INTEGRATION_STATISTICS`). + general : {'admitted', 'no_flow', 'diagnostic', 'medium'} or None + How a general modal basis treats it: admitted, admitted unless the block + couples (:data:`WITHHELD_WITH_A_FLOW`), a step diagnostic the basis does not + decide, refused because the guide has no gas fill or spatial geometry, or + (``None``) refused because of the basis. + advice : str or None + What to use instead on a general modal basis (:data:`ACCESSOR_ADVICE`). Examples -------- @@ -1246,9 +1229,23 @@ class StatDefinition: shape: str units: str requires: tuple[str, ...] = () - implemented: bool = True backend: bool = False between_planes: bool = False + evaluate: Callable[..., np.ndarray] | None = field( + default=None, repr=False, compare=False + ) + metric: bool = False + weight: Literal["poynting", "unit"] | None = None + profile: bool = False + plasma: Literal["on_axis", "integration"] | None = None + general: Literal["admitted", "no_flow", "diagnostic", "medium"] | None = None + advice: str | None = None + + +_DETECTOR_ADVICE = ( + "analytic_flux or reconstructed E/H with an explicit detector definition" +) +"""What replaces a peak power or intensity on a general modal basis.""" def _catalogue() -> dict[str, StatDefinition]: @@ -1260,6 +1257,10 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_modes, n_pol)", units="rad/s", between_planes=True, + evaluate=omega0, + metric=True, + weight="poynting", + general="admitted", ), StatDefinition( name="energy", @@ -1267,6 +1268,11 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_modes, n_pol); () for spatial beams", units="J", between_planes=True, + evaluate=energy, + weight="poynting", + advice=( + "physical_energy, which integrates the same-frequency metric product" + ), ), StatDefinition( name="energy_window", @@ -1275,6 +1281,12 @@ def _catalogue() -> dict[str, StatDefinition]: units="J", requires=("windows",), between_planes=True, + evaluate=energy_window, + weight="poynting", + advice=( + "physical_energy with an explicit band, or the reconstructed E/H of a " + "saved plane" + ), ), StatDefinition( name="peak_power", @@ -1282,6 +1294,9 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_modes,)", units="W", between_planes=True, + evaluate=peak_power, + weight="poynting", + advice=_DETECTOR_ADVICE, ), StatDefinition( name="peak_power_window", @@ -1290,6 +1305,9 @@ def _catalogue() -> dict[str, StatDefinition]: units="W", requires=("windows",), between_planes=True, + evaluate=peak_power_window, + weight="poynting", + advice=_DETECTOR_ADVICE, ), StatDefinition( name="peak_power_total", @@ -1297,6 +1315,9 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="W", between_planes=True, + evaluate=peak_power_total, + weight="poynting", + advice=_DETECTOR_ADVICE, ), StatDefinition( name="physical_energy", @@ -1304,12 +1325,20 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="J", between_planes=True, + evaluate=physical_energy, + metric=True, + weight="unit", + general="admitted", ), StatDefinition( name="spectral_flux", summary="Spectral density of a general modal basis's physical energy.", shape="(n_omega,)", units="J/(rad/s)", + evaluate=spectral_flux, + metric=True, + weight="unit", + general="admitted", ), StatDefinition( name="analytic_flux", @@ -1317,6 +1346,10 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="W", between_planes=True, + evaluate=analytic_flux, + metric=True, + weight="unit", + general="admitted", ), StatDefinition( name="fwhm_t", @@ -1324,6 +1357,10 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="s", between_planes=True, + evaluate=fwhm_t, + metric=True, + weight="poynting", + general="admitted", ), StatDefinition( name="fwhm_t_outer", @@ -1331,6 +1368,10 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="s", between_planes=True, + evaluate=fwhm_t_outer, + metric=True, + weight="poynting", + general="admitted", ), StatDefinition( name="density", @@ -1339,6 +1380,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", requires=("density",), between_planes=True, + profile=True, + general="medium", ), StatDefinition( name="pressure", @@ -1347,6 +1390,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="bar", requires=("pressure",), between_planes=True, + profile=True, + general="medium", ), StatDefinition( name="core_radius", @@ -1355,6 +1400,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="m", requires=("core_radius",), between_planes=True, + profile=True, + general="medium", ), StatDefinition( name="zdw", @@ -1362,6 +1409,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_modes,)", units="m", requires=("modes",), + general="no_flow", ), StatDefinition( name="species_ionisation", @@ -1370,6 +1418,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1", backend=True, between_planes=True, + general="medium", ), StatDefinition( name="species_electron_density", @@ -1378,6 +1427,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=True, + general="medium", ), StatDefinition( name="species_peak_ionisation_rate", @@ -1386,6 +1436,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/s", backend=True, between_planes=False, + general="medium", ), StatDefinition( name="species_density", @@ -1394,6 +1445,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=False, + general="medium", ), StatDefinition( name="species_ionisation_energy", @@ -1402,6 +1454,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="J/m", backend=True, between_planes=False, + general="medium", ), StatDefinition( name="electron_density", @@ -1410,6 +1463,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=True, + general="medium", ), StatDefinition( name="peak_ionisation_rate", @@ -1418,6 +1472,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/s", backend=True, between_planes=True, + general="medium", ), StatDefinition( name="on_axis_electron_density", @@ -1426,6 +1481,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=False, + plasma="on_axis", ), StatDefinition( name="on_axis_peak_ionisation_rate", @@ -1434,6 +1490,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/s", backend=True, between_planes=False, + plasma="on_axis", ), StatDefinition( name="on_axis_species_electron_density", @@ -1442,6 +1499,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=False, + plasma="on_axis", ), StatDefinition( name="on_axis_species_peak_ionisation_rate", @@ -1450,6 +1508,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/s", backend=True, between_planes=False, + plasma="on_axis", ), StatDefinition( name="plasma_undershoot", @@ -1458,6 +1517,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="1", backend=True, between_planes=False, + plasma="integration", + general="medium", ), StatDefinition( name="plasma_work_defect", @@ -1466,6 +1527,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="1", backend=True, between_planes=False, + plasma="integration", + general="medium", ), StatDefinition( name="plasma_boundary_level", @@ -1474,6 +1537,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="1", backend=True, between_planes=False, + plasma="integration", + general="medium", ), StatDefinition( name="peak_intensity", @@ -1482,6 +1547,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="W/m^2", backend=True, between_planes=True, + weight="poynting", + advice=_DETECTOR_ADVICE, ), StatDefinition( name="fwhm_r", @@ -1489,6 +1556,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="m", requires=("spatial_geometry",), + general="medium", ), StatDefinition( name="on_axis_intensity", @@ -1497,6 +1565,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="W/m^2", requires=("spatial_geometry",), between_planes=True, + general="medium", ), StatDefinition( name="on_axis_spectrum", @@ -1504,6 +1573,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_omega,)", units="J s/m^2", requires=("spatial_geometry",), + general="medium", ), StatDefinition( name="beam_radius", @@ -1511,6 +1581,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(2,)", units="m", requires=("spatial_geometry",), + general="medium", ), StatDefinition( name="aperture_spectrum", @@ -1518,6 +1589,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_apertures, n_omega)", units="J/(rad/s)", requires=("apertures", "spatial_geometry"), + general="medium", ), StatDefinition( name="aperture_on_axis_spectrum", @@ -1525,6 +1597,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_apertures, n_omega)", units="J s/m^2", requires=("apertures", "spatial_geometry"), + general="medium", ), StatDefinition( name="region_spectrum", @@ -1532,6 +1605,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_regions, n_omega)", units="J/(rad/s)", requires=("regions", "spatial_geometry"), + general="medium", ), StatDefinition( name="beam_spectrum", @@ -1539,6 +1613,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_omega,)", units="J/(rad/s)", requires=("spatial_geometry",), + general="medium", ), StatDefinition( name="absorbed_power", @@ -1548,6 +1623,7 @@ def _catalogue() -> dict[str, StatDefinition]: requires=("spatial_geometry", "separable_operator"), backend=True, between_planes=True, + general="medium", ), StatDefinition( name="quadrature_error", @@ -1556,25 +1632,22 @@ def _catalogue() -> dict[str, StatDefinition]: units="dimensionless", backend=True, between_planes=True, + general="diagnostic", ), ) return {entry.name: entry for entry in entries} CATALOGUE: Mapping[str, StatDefinition] = _catalogue() -"""Every statistic solprop knows about, implemented or reserved, keyed by name.""" +"""Every statistic solprop knows about, keyed by name.""" GENERAL_MODAL_ACCESSORS: frozenset[str] = frozenset( - { - # Statistics of the general basis, from the catalogue above. - "omega0", - "physical_energy", - "spectral_flux", - "analytic_flux", - "fwhm_t", - "fwhm_t_outer", - "zdw", - # Processing accessors of the general basis (solprop.processing.modal). + name + for name, entry in CATALOGUE.items() + if entry.general in ("admitted", "no_flow") +) | frozenset( + # Processing accessors of the general basis (solprop.processing.modal). + ( "reconstructed_flux", "channel_labels", "coordinate_intensity", @@ -1582,7 +1655,7 @@ def _catalogue() -> dict[str, StatDefinition]: "electric_field", "magnetic_field", "plane_nodes", - } + ) ) """Every accessor a three-component general modal basis admits, by name. @@ -1619,7 +1692,9 @@ def _catalogue() -> dict[str, StatDefinition]: for the right reason. """ -WITHHELD_WITH_A_FLOW: frozenset[str] = frozenset({"zdw"}) +WITHHELD_WITH_A_FLOW: frozenset[str] = frozenset( + name for name, entry in CATALOGUE.items() if entry.general == "no_flow" +) """The general-basis accessors a route withholds when its block couples, by name. A declared change of basis over channels whose propagation constants differ executes @@ -1631,23 +1706,7 @@ def _catalogue() -> dict[str, StatDefinition]: """ ACCESSOR_ADVICE: Mapping[str, str] = { - "energy": "physical_energy, which integrates the same-frequency metric product", - "energy_window": ( - "physical_energy with an explicit band, or the reconstructed E/H of a saved " - "plane" - ), - "peak_power": ( - "analytic_flux or reconstructed E/H with an explicit detector definition" - ), - "peak_power_total": ( - "analytic_flux or reconstructed E/H with an explicit detector definition" - ), - "peak_power_window": ( - "analytic_flux or reconstructed E/H with an explicit detector definition" - ), - "peak_intensity": ( - "analytic_flux or reconstructed E/H with an explicit detector definition" - ), + name: entry.advice for name, entry in CATALOGUE.items() if entry.advice is not None } """What to use instead of each legacy name on a general modal basis. @@ -1656,16 +1715,8 @@ def _catalogue() -> dict[str, StatDefinition]: ``analytic_flux`` and the reconstruction accessors, and nothing else of that family. """ -_GAS_OR_SPATIAL_NAMES: frozenset[str] = ( - frozenset({"electron_density", "peak_ionisation_rate"}) - | frozenset(PLASMA_INTEGRATION_STATISTICS) - | frozenset(PROFILE_STATISTICS) - | frozenset(name for name in CATALOGUE if name.startswith("species_")) - | frozenset( - name - for name, entry in CATALOGUE.items() - if "spatial_geometry" in entry.requires - ) +_GAS_OR_SPATIAL_NAMES: frozenset[str] = frozenset( + name for name, entry in CATALOGUE.items() if entry.general == "medium" ) """Names a general modal guide fails for a reason that is not its basis at all. @@ -1675,7 +1726,9 @@ def _catalogue() -> dict[str, StatDefinition]: be nonsense. The gate names that reason instead. """ -_STEP_DIAGNOSTICS: frozenset[str] = frozenset({"quadrature_error"}) +_STEP_DIAGNOSTICS: frozenset[str] = frozenset( + name for name, entry in CATALOGUE.items() if entry.general == "diagnostic" +) """Diagnostics of the step, which are the same quantity in any basis. Basis-independent step diagnostics are admitted on a general basis, so the gate never @@ -1687,34 +1740,9 @@ def _catalogue() -> dict[str, StatDefinition]: same record's run refuses. """ -_FIELD_STATISTICS: Mapping[ - str, Callable[[TimeGrid, Complex[np.ndarray, "n_modes n_pol n_omega"]], np.ndarray] -] = { - "energy": energy, - "peak_power": peak_power, - "peak_power_total": peak_power_total, -} -"""Statistics of the saved field alone, each taking the grid and the field.""" - -_METRIC_STATISTICS: Mapping[ - str, - Callable[..., np.ndarray], -] = { - "omega0": omega0, - "physical_energy": physical_energy, - "spectral_flux": spectral_flux, - "analytic_flux": analytic_flux, - "fwhm_t": fwhm_t, - "fwhm_t_outer": fwhm_t_outer, -} -"""Statistics of the saved field that also take the basis's flux metric. - -The annotation is ``Callable[..., ndarray]`` rather than the exact signature because -a keyword-only parameter cannot be written in a ``Callable`` at all; every member's -signature is ``(grid, field, *, metric=None)`` and :func:`collect` calls them that way. -""" - -METRIC_WEIGHTED_STATISTICS: frozenset[str] = frozenset(_METRIC_STATISTICS) +METRIC_WEIGHTED_STATISTICS: frozenset[str] = frozenset( + name for name, entry in CATALOGUE.items() if entry.metric +) """Statistics whose value depends on the basis's flux metric, by name. These are the functionals of the physical same-frequency flux @@ -1732,18 +1760,39 @@ def _catalogue() -> dict[str, StatDefinition]: degenerate block leaves every one of them unchanged. """ -_WINDOW_STATISTICS: Mapping[ - str, - Callable[ - [ - TimeGrid, - Complex[np.ndarray, "n_modes n_pol n_omega"], - Float[np.ndarray, "n_windows 2"], - ], - np.ndarray, - ], -] = {"energy_window": energy_window, "peak_power_window": peak_power_window} -"""Statistics that also take the spectral windows of the spec.""" +PROFILE_STATISTICS: tuple[str, ...] = tuple( + name for name, entry in CATALOGUE.items() if entry.profile +) +"""Statistics that read a :class:`solprop.profiles.ZProfile` at the plane's position.""" + +ON_AXIS_PLASMA_STATISTICS: tuple[str, ...] = tuple( + name for name, entry in CATALOGUE.items() if entry.plasma == "on_axis" +) +"""The plasma statistics evaluated on the waveguide's axis (SEP 19, section 5.2). + +Opt-in and saved-plane only. The axis field is synthesised from the mode profiles, as +for ``peak_intensity``, and its populations are scanned with exactly the rate tables, +preionisation, densities and running-integral rule of the node scan, once per +observation; a rule with a node on the axis supplies that row instead. They sit beside +the unchanged node reductions -- ``electron_density`` is the densest node's -- because a +few-node field-scale rule may have no node near the axis, and a multimode field's +largest density need not lie on it. Defined on resolved modal routes; the spatial +routes, general modal guides, the mode-averaged route and TDSE refuse them by name. +""" + +PLASMA_INTEGRATION_STATISTICS: tuple[str, ...] = tuple( + name for name, entry in CATALOGUE.items() if entry.plasma == "integration" +) +"""The field plasma column's run-time integration diagnostics (SEP 18, section 4.5). + +Evaluated with the other plasma statistics at each save plane. Each is a ratio of two +maxima over every sample, node and population, so that a node the field barely +ionises cannot set it: the undershoot ``max(-dS) / max(dS)`` of the exposure +increments; the work defect ``max |paid - freed| / max freed``, the loss current's work +per ionisation potential against the fraction it freed; and the boundary level, the +free-current integrand's largest magnitude in the ``2K`` samples the filter reads past +the window's start against its largest anywhere. +""" ApertureWeighting = Literal["amplitude", "quadrature"] @@ -2298,7 +2347,9 @@ def _no_regions() -> Float[np.ndarray, "n_regions 4"]: return np.empty((0, 4)) -_APERTURE_NAMES = frozenset({"aperture_spectrum", "aperture_on_axis_spectrum"}) +_APERTURE_NAMES = frozenset( + name for name, entry in CATALOGUE.items() if "apertures" in entry.requires +) """The statistics that read :attr:`StatsSpec.apertures`.""" @@ -2458,18 +2509,11 @@ def n_windows(self) -> int: # numpydoc ignore=RT01 def _check_names(names: Sequence[str]) -> None: - """Check that every requested statistic exists, is implemented and appears once.""" + """Check that every requested statistic exists and appears once.""" unknown = sorted(set(names) - set(CATALOGUE)) if unknown: msg = f"unknown statistics {unknown}; the catalogue holds {sorted(CATALOGUE)}" raise OutputError(msg) - reserved = sorted(name for name in names if not CATALOGUE[name].implemented) - if reserved: - msg = ( - f"the statistics {reserved} are reserved but not implemented: they need " - "physics that is not on main yet" - ) - raise OutputError(msg) repeated = sorted({name for name in names if list(names).count(name) > 1}) if repeated: msg = f"each statistic may be asked for once; these repeat: {repeated}" @@ -2680,36 +2724,37 @@ def collect( else {} ) for name in spec.names: - if flux is not None and name not in PROFILE_STATISTICS: + entry = CATALOGUE[name] + if flux is not None and not entry.profile: values[name] = ( far_field[name] if name in far_field else _spatial_statistic(name, flux, field) ) - elif "spatial_geometry" in CATALOGUE[name].requires: + elif "spatial_geometry" in entry.requires: msg = f"the {name!r} statistic requires spatial geometry" raise OutputError(msg) - elif name in _METRIC_STATISTICS: - values[name] = _METRIC_STATISTICS[name](grid, field, metric=metric) - elif name in _FIELD_STATISTICS: - values[name] = _FIELD_STATISTICS[name](grid, field) - elif name in _WINDOW_STATISTICS: + elif entry.evaluate is not None and entry.metric: + values[name] = entry.evaluate(grid, field, metric=metric) + elif entry.evaluate is not None and "windows" in entry.requires: if spec.n_windows == 0: msg = f"the {name!r} statistic needs at least one energy window" raise OutputError(msg) - values[name] = _WINDOW_STATISTICS[name](grid, field, spec.energy_windows) - elif CATALOGUE[name].backend: + values[name] = entry.evaluate(grid, field, spec.energy_windows) + elif entry.evaluate is not None: + values[name] = entry.evaluate(grid, field) + elif entry.backend: msg = ( f"the {name!r} statistic is computed by the backend from its own " "buffers, not from a saved plane; ask for it in the run's " "statistics (a StatsSpec)" ) raise OutputError(msg) - elif name in PROFILE_STATISTICS: + elif entry.profile: values[name] = profile_value(_profile_for(profiles, name), plane.z) else: # Only "zdw" is left: the spec has already checked every name against the - # catalogue, and every other implemented entry is routed above. + # catalogue, and every other entry is routed above. if modes is None: msg = "the 'zdw' statistic needs the propagated modes" raise OutputError(msg) @@ -2719,21 +2764,12 @@ def collect( _POYNTING_STATISTICS = frozenset( - ( - "energy", - "energy_window", - "omega0", - "peak_power", - "peak_power_total", - "peak_power_window", - "fwhm_t", - "fwhm_t_outer", - "peak_intensity", - ) + name for name, entry in CATALOGUE.items() if entry.weight == "poynting" ) +"""The statistics a backend forms in its weighted Poynting pass.""" _GENERAL_FLUX_STATISTICS = frozenset( - ("physical_energy", "spectral_flux", "analytic_flux") + name for name, entry in CATALOGUE.items() if entry.weight == "unit" ) """The three general modal statistics, which are **not** in `_POYNTING_STATISTICS`. diff --git a/tests/test_stats.py b/tests/test_stats.py index 85d57fb4..7fa20820 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -4,7 +4,6 @@ """Tests for the plane-statistics catalogue, its definitions and the collector.""" -import dataclasses import re from pathlib import Path @@ -404,16 +403,6 @@ def test_a_spec_accepts_the_implemented_radial_width(): assert CATALOGUE["fwhm_r"].requires == ("spatial_geometry",) -def test_a_spec_refuses_a_reserved_catalogue_entry(monkeypatch): - monkeypatch.setitem( - CATALOGUE, - "future_width", - dataclasses.replace(CATALOGUE["fwhm_r"], implemented=False), - ) - with pytest.raises(OutputError, match="reserved but not implemented"): - StatsSpec(names=("future_width",)) - - @pytest.mark.parametrize( "name", [ @@ -507,12 +496,12 @@ def test_every_periodic_default_is_one_the_catalogue_allows(): ], ids=["averaged", "averaged-full", "multimode", "multimode-full"], ) -def test_every_default_name_is_in_the_catalogue_and_implemented(kind_and_flags): +def test_every_default_name_is_in_the_catalogue(kind_and_flags): kind, with_profiles, with_zdw = kind_and_flags spec = default_spec(kind, with_profiles=with_profiles, with_zdw=with_zdw) assert spec.names for name in spec.names: - assert CATALOGUE[name].implemented + assert name in CATALOGUE def test_the_multimode_default_adds_the_total_peak_power(): @@ -649,8 +638,8 @@ def test_the_manual_page_states_the_trait_the_catalogue_holds(): cells = [line[7:] for line in body.splitlines() if line.startswith(" - ")] if name in CATALOGUE and len(cells) >= 4: rows[name] = cells[2] - # The catalogue tables include every implemented entry, including spatial widths. - documented = {name for name, entry in CATALOGUE.items() if entry.implemented} + # The catalogue tables include every entry, including spatial widths. + documented = set(CATALOGUE) assert set(rows) == documented for name, cell in rows.items(): assert cell == ("yes" if CATALOGUE[name].between_planes else "no"), name From 4388929d6150ea930c17de4b329d24566be2b8e4 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:11:58 +0000 Subject: [PATCH 22/42] Note the StatDefinition change in the changelog Co-Authored-By: Claude Opus 5.5 --- changelog.d/stat-definition-fields.removed.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/stat-definition-fields.removed.md diff --git a/changelog.d/stat-definition-fields.removed.md b/changelog.d/stat-definition-fields.removed.md new file mode 100644 index 00000000..f5926e53 --- /dev/null +++ b/changelog.d/stat-definition-fields.removed.md @@ -0,0 +1,6 @@ +`StatDefinition.implemented` is removed: every statistic in `solprop.stats.CATALOGUE` +is implemented, and the reserved-name refusal it served had no entries. Each +`StatDefinition` now also says how a statistic is formed and treated (`evaluate`, +`metric`, `weight`, `profile`, `plasma`, `general`, `advice`), and the name sets such as +`METRIC_WEIGHTED_STATISTICS` and `GENERAL_MODAL_ACCESSORS` are derived from those +fields, with unchanged members. From 7b816fb820525d78605db514e31723b1a46269a7 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:12:54 +0000 Subject: [PATCH 23/42] Give ZProfile the defaults of a continuous monotone path Constant, PiecewiseLinear, Linear and PressureGradient subclass it explicitly and drop their copies of jumps, derivative_is_analytic and variation. Co-Authored-By: Claude Opus 5.5 --- src/solprop/profiles.py | 97 ++++++----------------------------------- 1 file changed, 14 insertions(+), 83 deletions(-) diff --git a/src/solprop/profiles.py b/src/solprop/profiles.py index 89967654..5688e512 100644 --- a/src/solprop/profiles.py +++ b/src/solprop/profiles.py @@ -334,6 +334,11 @@ class ZProfile(Protocol): Implementations are frozen dataclasses. Every one is callable on an array of positions and returns an array of the same shape, so that it can be handed straight to :func:`solprop.translate.chebyshev.build_from_paths` as a path. + + A class that subclasses it explicitly takes the defaults of a continuous path + with an exact derivative, monotone between its breakpoints: no :attr:`jumps`, + :attr:`derivative_is_analytic` ``True`` and the :meth:`variation` of those + monotone pieces. A path that is not one overrides them. """ @property @@ -342,9 +347,9 @@ def breakpoints(self) -> Float[np.ndarray, " n_breakpoints"]: ... @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: + def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 """Return the declared discontinuities in m, a subset of the breakpoints.""" - ... + return np.empty(0, dtype=float) @property def z_span(self) -> tuple[float, float]: @@ -364,12 +369,12 @@ def variation(self, start: float, end: float) -> PathVariation: PathVariation Pieces on which the path's values and variation are enclosed. """ - ... + return _continuous_monotone(self, _interval_knots(start, end, self.breakpoints)) @property - def derivative_is_analytic(self) -> bool: + def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 """Whether :meth:`derivative` is exact rather than a finite difference.""" - ... + return True @property def is_constant(self) -> bool: @@ -596,7 +601,7 @@ def _mapped_variation( @dataclass(frozen=True, eq=False) -class Constant: +class Constant(ZProfile): """A path that does not vary along z. Attributes @@ -636,39 +641,11 @@ def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 """Interval ``(start, end)`` the path is defined over, m.""" return self.span - @property - def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 - """Whether the derivative is exact. - - Always ``True``: the derivative is identically zero. - """ - return True - @property def is_constant(self) -> bool: # numpydoc ignore=RT01 """Whether the path is the same everywhere.""" return True - @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 - """Declared discontinuities, m, which for a constant are none.""" - return np.empty(0, dtype=float) - - def variation(self, start: float, end: float) -> PathVariation: - """Return the exact variation, which is zero. - - Parameters - ---------- - start, end : float - The closed interval in m, finite with ``start < end``. - - Returns - ------- - PathVariation - One flat piece. - """ - return _continuous_monotone(self, _interval_knots(start, end, np.empty(0))) - def __call__(self, z: Float[np.ndarray, " n_z"]) -> Float[np.ndarray, " n_z"]: """Return the constant value at each position. @@ -701,7 +678,7 @@ def derivative(self, z: Float[np.ndarray, " n_z"]) -> Float[np.ndarray, " n_z"]: @dataclass(frozen=True, eq=False) -class PiecewiseLinear(Displayed): +class PiecewiseLinear(Displayed, ZProfile): """A path linear between given positions and constant outside them. Every position is a breakpoint, because the slope jumps there. Clamping outside the @@ -823,37 +800,11 @@ def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 """Interval ``(first position, last position)`` the stages cover, m.""" return float(self.positions[0]), float(self.positions[-1]) - @property - def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 - """Whether the derivative is exact.""" - return True - @property def is_constant(self) -> bool: # numpydoc ignore=RT01 """Whether every stage carries the same value.""" return bool(np.all(self.values == self.values[0])) - @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 - """Declared discontinuities, m, none for a continuous interpolant.""" - return np.empty(0, dtype=float) - - def variation(self, start: float, end: float) -> PathVariation: - """Return the exact variation: the path is monotone between its stages. - - Parameters - ---------- - start, end : float - The closed interval in m, finite with ``start < end``. - - Returns - ------- - PathVariation - One monotone piece per stage inside the interval, and flat pieces outside - the declared range. - """ - return _continuous_monotone(self, _interval_knots(start, end, self.positions)) - def __call__(self, z: Float[np.ndarray, " n_z"]) -> Float[np.ndarray, " n_z"]: """Return the interpolated value at each position. @@ -898,7 +849,7 @@ def derivative(self, z: Float[np.ndarray, " n_z"]) -> Float[np.ndarray, " n_z"]: @dataclass(frozen=True, eq=False) -class Linear(Displayed): +class Linear(Displayed, ZProfile): """A path varying linearly between two ends and constant outside them. A two-stage :class:`PiecewiseLinear`, named for the common case of a taper. @@ -968,11 +919,6 @@ def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 """Interval ``(0, length)`` the path varies over, m.""" return self._stages.z_span - @property - def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 - """Whether the derivative is exact.""" - return True - @property def is_constant(self) -> bool: # numpydoc ignore=RT01 """Whether the two ends carry the same value. @@ -981,11 +927,6 @@ def is_constant(self) -> bool: # numpydoc ignore=RT01 """ return self._stages.is_constant - @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 - """Declared discontinuities, m, which for a linear path are none.""" - return np.empty(0, dtype=float) - def variation(self, start: float, end: float) -> PathVariation: """Return the exact variation of the two-stage table. @@ -1353,7 +1294,7 @@ def hydraulic_integral( @dataclass(frozen=True, eq=False) -class PressureGradient(Displayed): +class PressureGradient(Displayed, ZProfile): r"""A steady-flow pressure fill, in bar, with every stage boundary a breakpoint. Laminar flow of a compressible gas through a narrow tube of constant bore makes @@ -1680,21 +1621,11 @@ def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 """Interval ``(first position, last position)`` the fill covers, m.""" return float(self.positions[0]), float(self.positions[-1]) - @property - def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 - """Whether the derivative is exact.""" - return True - @property def is_constant(self) -> bool: # numpydoc ignore=RT01 """Whether every stage carries the same pressure.""" return bool(np.all(self.pressures == self.pressures[0])) - @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 - """Declared discontinuities, m, which for a steady-flow fill are none.""" - return np.empty(0, dtype=float) - def variation(self, start: float, end: float) -> PathVariation: """Return the exact variation: the fill is monotone on every stage. From 874f67be6da1776a17aed7937a191dddbcf7d550 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:15:51 +0000 Subject: [PATCH 24/42] Share the scaling of DataPulse and TimeDataPulse in a private base Co-Authored-By: Claude Opus 5.5 --- docs/conf.py | 3 + src/solprop/pulses.py | 214 ++++++++++++++++++++---------------------- 2 files changed, 104 insertions(+), 113 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f7fa5a55..35243d29 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -148,6 +148,9 @@ # The private wall-pair base RectMode and SlabMode share, for # the same reason. ("py:class", "solprop.modes.rectangular._WallPairFill"), + # The private base of the two pulses from measured data, DataPulse and + # TimeDataPulse, for the same reason. + ("py:class", "solprop.pulses._MeasuredPulse"), # The private mixin that gives a class its printed block. It is a base # of every class that prints specially, so `show-inheritance` names it; it is not # part of the API reference for the same reason as above. diff --git a/src/solprop/pulses.py b/src/solprop/pulses.py index 0d09be90..c6d0b8c9 100644 --- a/src/solprop/pulses.py +++ b/src/solprop/pulses.py @@ -35,7 +35,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Final, Literal, Protocol, runtime_checkable +from typing import ClassVar, Final, Literal, Protocol, runtime_checkable import numpy as np from jaxtyping import Complex, Float, Inexact @@ -2121,7 +2121,91 @@ def _checked_spectral_phase( @dataclass(frozen=True, eq=False, kw_only=True) -class DataPulse(Displayed): +class _MeasuredPulse(Displayed): + """The scaling, further phase and delay a pulse from measured data is built with. + + What :class:`DataPulse` and :class:`TimeDataPulse` share; each declares its own + data and reads it in ``build``. + """ + + energy: float + phase: Sequence[float] | np.ndarray = () + t0: float = 0.0 + wavelength: float | None = None + _TITLE: ClassVar[str] + """The printed block's title: what the data is.""" + _CENTRE: ClassVar[str] + """Where the Taylor phase is expanded about when no wavelength is given.""" + + def _check_scaling(self) -> None: + """Refuse a scaling the build cannot honour, and freeze the further phase.""" + positive(self.energy, "energy", PulseError, parameter="energy") + if self.wavelength is not None: + positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") + object.__setattr__(self, "phase", _frozen_phase(self.phase)) + + def _data_centre(self) -> float: + """Return the data's spectral centre of mass, rad/s.""" + raise NotImplementedError + + def _data_text(self) -> str: + """Return the extent of the data, for printing.""" + raise NotImplementedError + + def _reference_frequency(self) -> float: + """Return the frequency the Taylor phase is expanded about, rad/s. + + - Returns: the frequency of ``wavelength``, else the data's spectral centre of + mass. + """ + if self.wavelength is not None: + return 2.0 * np.pi * C / self.wavelength + return self._data_centre() + + def __repr__(self) -> str: + """Return a one-line description with the data summarised, never dumped.""" + return line( + type(self).__name__, + [("", self._data_text()), ("energy", engineering(self.energy, "J"))], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + reference = ( + self._CENTRE + if self.wavelength is None + else engineering(self.wavelength, "m") + ) + return f"{type(self).__name__}: {self._TITLE}", [ + ("data", self._data_text()), + ("energy", engineering(self.energy, "J")), + ("further phase", f"{phase_text(self.phase)} about {reference}"), + ("further delay", engineering(self.t0, "s")), + ] + + def _stored( + self, optics: Complex[np.ndarray, " n_omega"], grid: TimeGrid + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return the stored spectrum of an optics-convention spectrum on the grid. + + The grid's spectral collar, the conversion to the internal convention, the + further phase and delay, and the energy scaling. + """ + optics = optics * grid.omega_window + spectrum = fourier.spectrum_to_internal(optics, kind=grid.kind) + phase = np.asarray(self.phase) + spectrum = spectrum * _phase_factor( + grid, phase, self.t0, self._reference_frequency() + ) + return _canonical(_rescale(spectrum, grid, self.energy, "energy")) + + +# repr=False keeps the base's summarising __repr__. +@dataclass(frozen=True, eq=False, kw_only=True, repr=False) +class DataPulse(_MeasuredPulse): """A pulse from a measured complex spectrum, interpolated onto the grid. The stored form is the spectrum because that is what a measurement gives: a spectral @@ -2191,11 +2275,9 @@ class DataPulse(Displayed): omega: Float[np.ndarray, " n_data"] spectrum: Complex[np.ndarray, " n_data_spectrum"] - energy: float spectral_phase: Float[np.ndarray, " n_data_phase"] | None = None - phase: Sequence[float] | np.ndarray = () - t0: float = 0.0 - wavelength: float | None = None + _TITLE: ClassVar[str] = "a measured complex spectrum" + _CENTRE: ClassVar[str] = "the data's centre" def __post_init__(self) -> None: """Validate the tabulated data and adopt read-only copies of the arrays. @@ -2227,14 +2309,11 @@ def __post_init__(self) -> None: if omega[0] <= 0.0 or np.any(np.diff(omega) <= 0.0): msg = "omega must be strictly increasing and positive, in rad/s" raise PulseError(msg) - positive(self.energy, "energy", PulseError, parameter="energy") - if self.wavelength is not None: - positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") + self._check_scaling() omega.setflags(write=False) spectrum.setflags(write=False) object.__setattr__(self, "omega", omega) object.__setattr__(self, "spectrum", spectrum) - object.__setattr__(self, "phase", _frozen_phase(self.phase)) if self.spectral_phase is not None: object.__setattr__( self, @@ -2251,51 +2330,16 @@ def reference_wavelength(self) -> float: # numpydoc ignore=RT01 """ return 2.0 * np.pi * C / self._reference_frequency() - def _reference_frequency(self) -> float: - """Return the frequency the Taylor phase is expanded about, rad/s. - - - Returns: the frequency of ``wavelength``, else the weighted mean of ``omega``. - """ - if self.wavelength is not None: - return 2.0 * np.pi * C / self.wavelength + def _data_centre(self) -> float: + """Return the weighted mean of ``omega``, rad/s.""" weight = np.abs(self.spectrum) ** 2 return float(np.sum(self.omega * weight) / np.sum(weight)) - def __repr__(self) -> str: - """Return a one-line description with the data summarised, never dumped. - - Returns - ------- - str - For example ``DataPulse(<512 values, 600 nm to 1 um>, energy=1 uJ)``. - """ - return line( - "DataPulse", - [("", self._data_text()), ("energy", engineering(self.energy, "J"))], - ) - def _data_text(self) -> str: """Return the extent of the measured spectrum, for printing.""" wavelengths = 2.0 * np.pi * C / np.asarray(self.omega, dtype=float) return summarise(wavelengths, "m") - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - reference = ( - "the data's centre" - if self.wavelength is None - else engineering(self.wavelength, "m") - ) - return "DataPulse: a measured complex spectrum", [ - ("data", self._data_text()), - ("energy", engineering(self.energy, "J")), - ("further phase", f"{phase_text(self.phase)} about {reference}"), - ("further delay", engineering(self.t0, "s")), - ] - def build(self, grid: TimeGrid) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: """Return the initial spectrum of the tabulated pulse on the given grid. @@ -2360,13 +2404,7 @@ def build(self, grid: TimeGrid) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: optics[inside] = np.clip(magnitude(wanted), 0.0, None) * np.exp( 1j * angle(wanted) ) - optics = optics * grid.omega_window - spectrum = fourier.spectrum_to_internal(optics, kind=grid.kind) - phase = np.asarray(self.phase) - spectrum = spectrum * _phase_factor( - grid, phase, self.t0, self._reference_frequency() - ) - return _canonical(_rescale(spectrum, grid, self.energy, "energy")) + return self._stored(optics, grid) @classmethod def from_spectral_density( @@ -2481,8 +2519,9 @@ def _record_spectrum( return on_axis[index] * np.exp(1j * omega * float(t[0])) -@dataclass(frozen=True, eq=False, kw_only=True) -class TimeDataPulse(Displayed): +# repr=False keeps the base's summarising __repr__. +@dataclass(frozen=True, eq=False, kw_only=True, repr=False) +class TimeDataPulse(_MeasuredPulse): """A pulse from a measured field in time, transformed exactly onto the grid. The stored form is the record, and :meth:`build` takes out its mean ``E_mean`` and @@ -2548,10 +2587,8 @@ class TimeDataPulse(Displayed): t: Float[np.ndarray, " n_data"] field: Inexact[np.ndarray, " n_data_field"] - energy: float - phase: Sequence[float] | np.ndarray = () - t0: float = 0.0 - wavelength: float | None = None + _TITLE: ClassVar[str] = "a measured field in time" + _CENTRE: ClassVar[str] = "the record's centre" def __post_init__(self) -> None: """Validate the record and adopt read-only copies of the arrays. @@ -2587,17 +2624,14 @@ def __post_init__(self) -> None: if steps[0] <= 0.0 or not np.allclose(steps, steps[0], rtol=1e-9, atol=0.0): msg = "t must be uniformly spaced and increasing" raise PulseError(msg) - positive(self.energy, "energy", PulseError, parameter="energy") - if self.wavelength is not None: - positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") + self._check_scaling() times.setflags(write=False) values.setflags(write=False) object.__setattr__(self, "t", times) object.__setattr__(self, "field", values) - object.__setattr__(self, "phase", _frozen_phase(self.phase)) - self._spectral_centre() + self._data_centre() - def _spectral_centre(self) -> float: + def _data_centre(self) -> float: """Return the spectral centre of mass of the record, rad/s. - Returns: the mean of the positive frequencies of the record's DFT, by its @@ -2623,50 +2657,10 @@ def reference_wavelength(self) -> float: # numpydoc ignore=RT01 """ return 2.0 * np.pi * C / self._reference_frequency() - def _reference_frequency(self) -> float: - """Return the frequency the Taylor phase is expanded about, rad/s. - - - Returns: the frequency of ``wavelength``, else the record's spectral centre of - mass. - """ - if self.wavelength is not None: - return 2.0 * np.pi * C / self.wavelength - return self._spectral_centre() - - def __repr__(self) -> str: - """Return a one-line description with the record summarised, never dumped. - - Returns - ------- - str - For example ``TimeDataPulse(<1024 values, 0 s to 1 ps>, energy=1 uJ)``. - """ - return line( - "TimeDataPulse", - [("", self._data_text()), ("energy", engineering(self.energy, "J"))], - ) - def _data_text(self) -> str: """Return the extent of the measured record, for printing.""" return summarise(np.asarray(self.t, dtype=float), "s") - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - reference = ( - "the record's centre" - if self.wavelength is None - else engineering(self.wavelength, "m") - ) - return "TimeDataPulse: a measured field in time", [ - ("data", self._data_text()), - ("energy", engineering(self.energy, "J")), - ("further phase", f"{phase_text(self.phase)} about {reference}"), - ("further delay", engineering(self.t0, "s")), - ] - def _warn_if_folded( self, values: Inexact[np.ndarray, " n_data"], grid: TimeGrid ) -> None: @@ -2737,13 +2731,7 @@ def build(self, grid: TimeGrid) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: optics[inside] = _record_spectrum( self.t, values, grid.omega[inside], grid.d_omega ) - optics = optics * grid.omega_window - spectrum = fourier.spectrum_to_internal(optics, kind=grid.kind) - phase = np.asarray(self.phase) - spectrum = spectrum * _phase_factor( - grid, phase, self.t0, self._reference_frequency() - ) - return _canonical(_rescale(spectrum, grid, self.energy, "energy")) + return self._stored(optics, grid) # --- measured spectra from a file ----------------------------------------------------- From 5cec81df39fc94cdac9cdf35619ef76d92b17c35 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:16:23 +0000 Subject: [PATCH 25/42] Tighten the cross-package private-import ratchet to 85 Co-Authored-By: Claude Opus 5.5 --- tools/size_budget.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/size_budget.toml b/tools/size_budget.toml index abd248a1..134239bf 100644 --- a/tools/size_budget.toml +++ b/tools/size_budget.toml @@ -27,4 +27,4 @@ max_lines = 4000 [private_imports] # Private names imported from another package (the root's shared private modules, such # as solprop._process, count only for their private names). -max_cross_package = 87 +max_cross_package = 85 From 2d6ad57945fcebe12d87e20939a27100e06e090e Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:17:00 +0000 Subject: [PATCH 26/42] Rewrap the recipe docstrings the tools references lengthened Co-Authored-By: Claude Opus 5.5 --- src/solprop/recipe/pins.py | 6 +++--- src/solprop/recipe/preview.py | 20 +++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/solprop/recipe/pins.py b/src/solprop/recipe/pins.py index e95722d8..72b178fa 100644 --- a/src/solprop/recipe/pins.py +++ b/src/solprop/recipe/pins.py @@ -440,9 +440,9 @@ def solve_pulse( """Return the energy or peak power that gives a pulse a soliton order. Closed form on the launch mode at the launch position, with the fill's Kerr - coefficient (:func:`solprop.tools.soliton.soliton_peak_power`). The route is built once, - the pulse at a placeholder, for its mode. This is the call the Python export of a - recipe with a ``soliton_order`` pin writes before the route's call. + coefficient (:func:`solprop.tools.soliton.soliton_peak_power`). The route is built + once, the pulse at a placeholder, for its mode. This is the call the Python export + of a recipe with a ``soliton_order`` pin writes before the route's call. Parameters ---------- diff --git a/src/solprop/recipe/preview.py b/src/solprop/recipe/preview.py index acca40a9..f93098eb 100644 --- a/src/solprop/recipe/preview.py +++ b/src/solprop/recipe/preview.py @@ -83,12 +83,13 @@ class ModePreview: phase_mismatch : numpy.ndarray or None The phase mismatch in rad/m between a dispersive wave in this mode and a soliton in the launch mode (the set's first) at the pump - (:func:`solprop.tools.phase_matching.phase_mismatch`); ``None`` without a pump. See - :func:`modes` for the pump and the nonlinear term. + (:func:`solprop.tools.phase_matching.phase_mismatch`); ``None`` without a pump. + See :func:`modes` for the pump and the nonlinear term. rdw : tuple of float The phase-matched wavelengths in m, shortest first, between the band's - short edge and 0.9 of the pump (:func:`solprop.tools.phase_matching.lambda_rdw_all`); - empty without a pump or a root. + short edge and 0.9 of the pump + (:func:`solprop.tools.phase_matching.lambda_rdw_all`); empty without a pump or a + root. """ label: str @@ -210,9 +211,9 @@ def modes(recipe: "Recipe", z: float | None = None) -> ModeSetPreview: mode (the launch mode's own for the first; intermodal for the rest). With an unchirped sech or Gaussian pulse given by its FWHM and its energy or peak power, and a fill with Kerr data, the soliton's nonlinear wavenumber is that of its - largest constituent (:func:`solprop.tools.phase_matching.phase_mismatch`'s default); otherwise, - and for a pump in normal dispersion, which launches no soliton, the condition is - the linear one. + largest constituent (:func:`solprop.tools.phase_matching.phase_mismatch`'s + default); otherwise, and for a pump in normal dispersion, which launches no soliton, + the condition is the linear one. """ from solprop.modes.base import ( # noqa: PLC0415 aeff, @@ -284,8 +285,9 @@ def _pump( # noqa: PLR0911 - one return per case of the linear condition - ``z``: the position, m: the fill's coefficient, the mode's area and its dispersion are taken there, as the mismatch is. - Returns: the pump wavelength in m (``None`` without a numeric ``wavelength``), and - the keywords :func:`solprop.tools.phase_matching.phase_mismatch` takes for the nonlinear term - (``peak_power``, ``gamma``, ``fwhm``, ``shape``), empty for the linear condition. + the keywords :func:`solprop.tools.phase_matching.phase_mismatch` takes for the + nonlinear term (``peak_power``, ``gamma``, ``fwhm``, ``shape``), empty for the + linear condition. The nonlinear coefficient is the recipe's ``gamma`` where it gives one (the GNLSE), else the launch mode's fill's at `z`. The condition is linear for a chirped pulse (a ``phases`` term from second order on), a pump in normal From 74e96948df8cb7222e1917c1b4f6edfccd9d99e0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:17:36 +0000 Subject: [PATCH 27/42] Record the resume-key change of the record split The run record names its record types by module, so the split changes every configuration hash once: the display test's record skeleton is re-pinned, and the release note says old files are not resumed. Co-Authored-By: Claude Opus 5.5 --- changelog.d/output-schema-split.breaking.md | 5 +++-- tests/test_display.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/changelog.d/output-schema-split.breaking.md b/changelog.d/output-schema-split.breaking.md index c08cc692..a2956c9d 100644 --- a/changelog.d/output-schema-split.breaking.md +++ b/changelog.d/output-schema-split.breaking.md @@ -6,5 +6,6 @@ record types), `solprop.output.metadata` (`RunMetadata`, `SCHEMA_VERSION`, `resolve_metadata_write`) and `solprop.output.store` (`Output`, `PlaneStore`, `FieldRetention` and the other save protocols). Import these names from `solprop.output`, which still exports the public ones, or from their new module. A run -whose input array was recorded by its digest names the digest's class by its new module, -so its configuration hash differs from one written before. +record names its record types by module (`solprop.output.records.GridRecord`), so every +configuration hash differs from the one an earlier version wrote: a file or a scan +written before cannot be resumed or continued, and is read as it was. diff --git a/tests/test_display.py b/tests/test_display.py index d67d42ad..fb5eea82 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -644,7 +644,7 @@ def without_optmat_release(value: object, *, materials: bool = False) -> object: return value -RUN_RECORD_SKELETON = "5593ac1b5b5119b54c39de2d2f60da0a5b6f04610f61fce8eedd0a9d9c6a6fc4" +RUN_RECORD_SKELETON = "f21c76f5c17db893aa436cc88749c124d41e7052ceb28cdd8c01a20e4e442121" """The structure of the example run's record, as the projection below takes it.""" From 9d425f09f8c4b3814a1a55e54053a844a80e33d9 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:20:22 +0000 Subject: [PATCH 28/42] Merge from_radial into from_free; compose prop_* from their constructors from_free and prop_free take a RadialGrid as well as a FreeGrid or LineGrid and choose the transform from it; from_radial and prop_radial are removed (changelog fragment), and recipes keep the radial route. Each prop_* keeps its typed signature, now the constructor's followed by the run controls, and its body is _propagate(Simulation.from_x, locals()). @_propagates(Simulation.from_x) composes its Parameters section from the constructor's and the shared run-controls entries, so each keyword is documented once, on from_x (which takes prop_x's fuller entries where it had shorter ones). test_route_keywords checks the signatures and runs numpydoc's validate on the composed docstrings. Co-Authored-By: Claude Opus 5.5 --- benchmarks/builders.py | 2 +- benchmarks/registry.py | 8 +- .../driver-simulation-split.breaking.md | 7 + changelog.d/free-takes-radial.breaking.md | 5 + docs/api/index.md | 2 +- docs/dev/algorithms.md | 4 +- docs/dev/benchmarking.md | 2 +- docs/dev/gui/design.md | 2 +- docs/dev/seps/sep-0005-solid-ionisation.md | 4 +- docs/dev/stepper.md | 2 +- docs/guide/free-space/beams.md | 10 +- docs/guide/free-space/flux.md | 4 +- docs/guide/free-space/model.md | 10 +- docs/guide/media/material-selection.md | 4 +- docs/guide/media/noise.md | 2 +- docs/guide/numerics/adams.md | 4 +- docs/guide/numerics/boundaries.md | 2 +- docs/guide/numerics/choosing-a-stepper.md | 2 +- docs/guide/numerics/solver.md | 4 +- docs/guide/performance/tuning.md | 4 +- docs/guide/results/processing.md | 2 +- docs/guide/results/resuming.md | 2 +- docs/guide/setup/model-selection.md | 2 +- docs/guide/setup/recipes.md | 6 +- docs/guide/setup/running.md | 3 +- docs/guide/setup/spatial-decompositions.md | 4 +- docs/guide/workflows/interactive.md | 2 +- .../tutorials/plot_03_filament.py | 2 +- docs/tutorials/free_space/plot_01_radial.py | 4 +- .../plot_08_radial_capillary_coupling.py | 2 +- .../free_space/plot_09_transverse_window.py | 2 +- .../free_space/plot_10_radial_kerr.py | 6 +- .../free_space/plot_11_radial_filament.py | 6 +- src/solprop/calibration/problems.py | 2 +- src/solprop/driver/__init__.py | 2 - src/solprop/driver/defaults.py | 5 +- src/solprop/driver/interface.py | 1459 ++--------------- src/solprop/driver/settings.py | 7 +- src/solprop/driver/simulation.py | 403 ++--- src/solprop/processing/planes.py | 2 +- src/solprop/recipe/export.py | 9 +- src/solprop/recipe/metadata.py | 7 +- src/solprop/sol.py | 2 - tests/backends/test_far_field_statistics.py | 4 +- tests/backends/test_flux_gradients.py | 2 +- tests/backends/test_flux_parity.py | 2 +- tests/backends/test_flux_paths.py | 2 +- tests/backends/test_native_edge_collar.py | 2 +- tests/backends/test_native_edge_reading.py | 4 +- tests/backends/test_native_widths.py | 2 +- tests/backends/test_transverse_backends.py | 2 +- tests/capability_routes.py | 2 +- tests/coupling/test_couple.py | 2 +- tests/coupling/test_transport_plane.py | 2 +- tests/coupling/test_workflows.py | 4 +- tests/driver/test_absorber_keywords.py | 2 +- tests/driver/test_backend_default.py | 3 +- tests/driver/test_batch.py | 2 +- tests/driver/test_coherent_capillary.py | 2 +- tests/driver/test_coupling_relaunch.py | 2 +- tests/driver/test_harmonic_frame.py | 2 +- tests/driver/test_history_replay.py | 2 +- tests/driver/test_initial_z_and_checkpoint.py | 2 +- tests/driver/test_interface_defaults.py | 18 +- tests/driver/test_material_manifest.py | 4 +- tests/driver/test_mixture_update.py | 4 +- tests/driver/test_native_z_table.py | 2 +- tests/driver/test_noise_default.py | 2 +- tests/driver/test_prop_radial.py | 16 +- tests/driver/test_route_keywords.py | 57 + tests/driver/test_scan_derived_noise.py | 2 +- tests/driver/test_summary.py | 4 +- tests/processing/test_mixtures.py | 4 +- tests/processing/test_planes.py | 2 +- tests/processing/test_spatial_readers.py | 3 +- tests/recipe/test_examples.py | 2 - tests/recipe/test_free_space.py | 4 +- tests/recipe/test_metadata.py | 5 +- tests/record_routes.py | 2 +- .../test_native_session_statistics.py | 3 +- tests/test_display.py | 2 +- tests/test_monitor.py | 2 +- tests/test_spatial_boundaries.py | 8 +- tests/test_split_crossing.py | 4 +- tests/test_transverse_nonlinear.py | 2 +- tests/test_transverse_resolution.py | 4 +- tests/translate/test_coupling.py | 2 +- tests/translate/test_operator_settings.py | 2 +- tests/translate/test_power_boundaries.py | 4 +- tests/translate/test_problem.py | 2 +- tests/translate/test_routes.py | 2 +- 91 files changed, 540 insertions(+), 1700 deletions(-) create mode 100644 changelog.d/driver-simulation-split.breaking.md create mode 100644 changelog.d/free-takes-radial.breaking.md diff --git a/benchmarks/builders.py b/benchmarks/builders.py index 2a838b81..0ad74db5 100644 --- a/benchmarks/builders.py +++ b/benchmarks/builders.py @@ -474,7 +474,7 @@ def spatial( # at translation, its error measured at 1.7e-6 in field. settings = dataclasses.replace(settings, spatial_absorber=SpatialAbsorber()) if isinstance(space, RadialGrid): - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, responses, pulse, settings=settings, **_launch(options) ) return Simulation.from_free( diff --git a/benchmarks/registry.py b/benchmarks/registry.py index 197e2298..47eda676 100644 --- a/benchmarks/registry.py +++ b/benchmarks/registry.py @@ -321,7 +321,7 @@ def configuration( "radial_filament_256", 2, ("tracking", "free-space"), - "prop_radial", + "prop_free", "spatial", 0.03, (384, 864, 1728), @@ -439,7 +439,7 @@ def configuration( "radial_filament_512", 2, ("free-space",), - "prop_radial", + "prop_free", "spatial", 0.03, (768, 864, 1728), @@ -495,7 +495,7 @@ def configuration( "spatial_absorber", 2, ("free-space",), - "prop_radial", + "prop_free", "spatial", 0.08, (230, 576, 1152), @@ -704,7 +704,7 @@ def configuration( output=output, ) for base, interface, length, dimensions in ( - ("radial_filament_256", "prop_radial", 3e-3, (384, 864, 1728)), + ("radial_filament_256", "prop_free", 3e-3, (384, 864, 1728)), ("cartesian_filament_128", "prop_free", 1e-3, (128, 128, 864, 1728)), ) for kept in ("saved", "free") diff --git a/changelog.d/driver-simulation-split.breaking.md b/changelog.d/driver-simulation-split.breaking.md new file mode 100644 index 00000000..d69dc0bd --- /dev/null +++ b/changelog.d/driver-simulation-split.breaking.md @@ -0,0 +1,7 @@ +`solprop.driver.simulation` now holds the `Simulation` class alone; the rest moved +into modules by concern. Import `SimulationSettings` and `default_collar` from +`solprop.driver.settings` (or `SimulationSettings` from `solprop.driver`), +`SimulationRunner` and `check_derived_problem` from `solprop.driver.runner`, +`metadata_for` from `solprop.driver.metadata`, `default_field_range` from +`solprop.driver.inputs` and `default_radial_order` from +`solprop.driver.routes.hollow`; the names `solprop.driver` exports are unchanged. diff --git a/changelog.d/free-takes-radial.breaking.md b/changelog.d/free-takes-radial.breaking.md new file mode 100644 index 00000000..d7f01ba2 --- /dev/null +++ b/changelog.d/free-takes-radial.breaking.md @@ -0,0 +1,5 @@ +`Simulation.from_radial` and `prop_radial` are removed: `Simulation.from_free` and +`prop_free` take a `RadialGrid` as well as a `FreeGrid` or `LineGrid`, and choose the +radial or the Cartesian transform from it, with the same keywords and the same +defaults as before for each grid. Replace `from_radial(...)` with `from_free(...)` and +`prop_radial(...)` with `prop_free(...)`; recipes keep the `radial` route. diff --git a/docs/api/index.md b/docs/api/index.md index 1d16c8c0..d3af7938 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -20,7 +20,7 @@ In scripts and library code, import from the home modules listed below. | module | what it holds | |---|---| -| {mod}`solprop.driver` | the entry points `prop_gnlse`, `prop_capillary`, `prop_waveguide`, `prop_radial`, `prop_free` and `prop_planar`, the `Simulation` object behind them, and `run_problem`, the loop over save planes that takes a translated problem, a constructed backend, an output handler and a `Start` (`solprop.backends.session.Start.entrance(problem)` for a fresh run), which the run loads into the backend, for a batch runner of your own | +| {mod}`solprop.driver` | the entry points `prop_gnlse`, `prop_capillary`, `prop_waveguide`, `prop_free` and `prop_planar`, the `Simulation` object behind them, and `run_problem`, the loop over save planes that takes a translated problem, a constructed backend, an output handler and a `Start` (`solprop.backends.session.Start.entrance(problem)` for a fresh run), which the run loads into the backend, for a batch runner of your own | | {mod}`solprop.grid` | time and frequency grids (`FieldGrid`, `EnvelopeGrid`) and the free-space transverse grids (`RadialGrid`, `FreeGrid`, `LineGrid`) | | {mod}`solprop.pulses` | initial pulses: `GaussianPulse`, `SechPulse`, `CustomPulse`, `DataPulse`, `TimeDataPulse`, `load_spectrum` (a measured spectrum and phase from a text file), and spatial and planar pulses | | {mod}`solprop.profiles` | quantities that vary along z: radius, pressure and temperature profiles such as `PiecewiseLinear` and `PressureGradient` | diff --git a/docs/dev/algorithms.md b/docs/dev/algorithms.md index 0389e461..d4a58951 100644 --- a/docs/dev/algorithms.md +++ b/docs/dev/algorithms.md @@ -78,8 +78,8 @@ not a converged answer. ## 2. The Runge–Kutta pairs This section is the tableau theory of `RungeKutta()`, the explicit march and the default -integrator of `prop_gnlse`, `prop_waveguide` and `prop_free`. `Multistep()`, the default -of `prop_capillary`, `prop_radial` and `prop_planar` (ADR-055), is the low-call +integrator of `prop_gnlse`, `prop_waveguide` and `prop_free` on a Cartesian grid. `Multistep()`, the default +of `prop_capillary`, `prop_free` on a radial grid and `prop_planar` (ADR-055), is the low-call exponential Adams method: its formulation and history management are in the [solver guide](../guide/numerics/adams.md) and the [stepper design](stepper.md#low-call-adams-methods). diff --git a/docs/dev/benchmarking.md b/docs/dev/benchmarking.md index b674c4ed..921ce5e8 100644 --- a/docs/dev/benchmarking.md +++ b/docs/dev/benchmarking.md @@ -36,7 +36,7 @@ selection it is the tracking set: | `argon_blueshift_envelope` | `prop_capillary` | the same on the envelope grid | 1, production | | `h2_raman_averaged` | `prop_capillary` | H2 soliton self-frequency shift, 0.4 m | 1, production | | `ctc` | `prop_capillary` | H2 comb-to-continuum, three modes, 0.13 m | production | -| `radial_filament_256` | `prop_radial` | Ar filament, 256 radial nodes, 3 cm | 1, production | +| `radial_filament_256` | `prop_free` | Ar filament, 256 radial nodes, 3 cm | 1, production | | `grin_nonlinear` | `prop_waveguide` | four-channel graded-index fibre, 2 cm | production | | `kerr_beam_384` | `prop_free` | fused-silica Kerr beam, 384², four fixed RK4IP steps | production | | `kerr_beam_192` | `prop_free` | the same at 192² | 1 | diff --git a/docs/dev/gui/design.md b/docs/dev/gui/design.md index 12d823eb..03dfbba7 100644 --- a/docs/dev/gui/design.md +++ b/docs/dev/gui/design.md @@ -338,7 +338,7 @@ definition, and no view asks which class it is drawing. The first four classes: | Class | solprop route | Notes | |---|---|---| | Capillary | `prop_capillary` | Mode-averaged, or multimode through the sheet's Modes field, so one tile covers both. A full-transverse rule and a coherent wall are under Advanced. | -| Radial filament | `prop_radial` | Needs a filament example (21.2). | +| Radial filament | `prop_free` | Needs a filament example (21.2). | | Solid-core fibre | `prop_gnlse` | Taylor dispersion, then step-index and tables as recipes allow them (#472, #473). | | Antiresonant fibre | `prop_capillary(lattice=TubeLattice(...))` | Adds the lattice to the sheet and a loss readout. | diff --git a/docs/dev/seps/sep-0005-solid-ionisation.md b/docs/dev/seps/sep-0005-solid-ionisation.md index fafc8f1c..0c901a78 100644 --- a/docs/dev/seps/sep-0005-solid-ionisation.md +++ b/docs/dev/seps/sep-0005-solid-ionisation.md @@ -108,7 +108,7 @@ implementation; the hashes document the evidence, not package-version pins. | Existing component | Reusable part | Required difference | |---|---|---| | `solprop.spatial.SpatialMedium` and `SpatialDispersion`; [free space](../../guide/free-space/model.md), [Cartesian propagation](../../guide/free-space/cartesian.md) | Bulk dispersion, longitudinal square root, diffraction, physical spatial fields and UPPE prefactors | A nongas medium currently has dimensionless `density=1`. That cannot become an available carrier density | -| `driver.interface.prop_radial`, `prop_free` and `Simulation.from_radial/from_free` | Launch, spatial geometry, adaptive/fixed stepping, save planes | Admit an explicit solid response and normalise its launch with its medium | +| `driver.interface.prop_free` and `Simulation.from_free` | Launch, spatial geometry, adaptive/fixed stepping, save planes | Admit an explicit solid response and normalise its launch with its medium | | `nonlinear.responses.PlasmaResponse` | Memory-response organisation, zero-field handling, physical-current accounting | Gas species, single-event survival and collisionless free-electron current do not describe a solid | | `nonlinear.ionisation` and `translate.ionisation` | Rate protocol, table/cache machinery and accuracy checks; after SEP 3 these tabulate optmat rate models | The `keldysh` helper is a gas adiabaticity parameter, not a solid production rate; gas rates cannot be parameterised by a bandgap. A solid rate is a distinct optmat model with its own protocol | | [Envelope plasma](../../guide/media/envelope-plasma.md), `translate.envelope_plasma` | Separate current-source streams and explicit spectral assembly | Gas cycle quadrature starts with an instantaneous rate. Solid Keldysh is already cycle averaged and must not be averaged again | @@ -673,7 +673,7 @@ medium = SpatialMedium.from_optmat() responses = [, plasma] # Use existing grid, spatial pulse and settings constructors. # Pulse energy and focus refer to just inside the entrance face. -result = prop_radial(grid, space, medium, responses, pulse, settings=settings) +result = prop_free(grid, space, medium, responses, pulse, settings=settings) ``` No convenience `plasma=True` chooses a solid model implicitly. Scans vary diff --git a/docs/dev/stepper.md b/docs/dev/stepper.md index 34afba93..a0489fd9 100644 --- a/docs/dev/stepper.md +++ b/docs/dev/stepper.md @@ -304,7 +304,7 @@ a Cartesian Kerr beam, `tests/backends/test_low_storage.py`). ### Low-call Adams methods `stepper/multistep.py` and `backends/numpy_ref/multistep.py` hold the Adams integrator, the default -of `prop_capillary`, `prop_radial` and `prop_planar` (ADR-037, ADR-045, ADR-055; +of `prop_capillary`, `prop_free` on a radial grid and `prop_planar` (ADR-037, ADR-045, ADR-055; `stepper/runge_kutta.py`'s `RungeKutta` names the tableau's march, the default of `prop_gnlse`, `prop_waveguide` and `prop_free`). `Multistep` is an Adams march with a derivative history, in the exponential formulation diff --git a/docs/guide/free-space/beams.md b/docs/guide/free-space/beams.md index 2ba42d52..57cc4b71 100644 --- a/docs/guide/free-space/beams.md +++ b/docs/guide/free-space/beams.md @@ -33,7 +33,7 @@ media each accepts are defined in [the free-space model](model.md): | grid | resolves | use it for | entry point | |---|---|---|---| -| {py:class}`~solprop.grid.RadialGrid` | $r$, assuming each polarisation component is cylindrically symmetric | round beams on axis: focusing, self-focusing, a single filament; the cheapest | `prop_radial`, `Simulation.from_radial` | +| {py:class}`~solprop.grid.RadialGrid` | $r$, assuming each polarisation component is cylindrically symmetric | round beams on axis: focusing, self-focusing, a single filament; the cheapest | `prop_free`, `Simulation.from_free` | | {py:class}`~solprop.grid.FreeGrid` | $x$ and $y$, a periodic box | anything without that symmetry: a tilted, astigmatic or elliptical beam, several filaments, beam break-up | `prop_free`, `Simulation.from_free` | | {py:class}`~solprop.grid.LineGrid` | $x$ alone; uniform in $y$ | a sheet beam, per metre of $y$ | `prop_free` | @@ -81,7 +81,7 @@ through argon with the Kerr effect, on an envelope grid, saving ten planes: import numpy as np from solprop.boundaries import absorber -from solprop.driver import SimulationSettings, prop_radial +from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.responses import KerrResponse from solprop.constants import C @@ -111,7 +111,7 @@ settings = SimulationSettings( stats=StatsSpec(names=("energy", "beam_radius", "peak_intensity")), absorber=absorber(grid), ) -run = prop_radial( +run = prop_free( grid, space, argon, @@ -167,7 +167,7 @@ focusing = SpatialPulse( propz=-5e-3, # the waist is 5 mm into the run ) launch = focusing.build(grid, space, medium=SpatialMedium()) # focused in vacuum -through_waist = prop_radial( +through_waist = prop_free( grid, space, argon, [], launch, settings=settings, progress="none" ) radius = np.asarray(through_waist["stats/beam_radius"])[..., 0].reshape(-1) @@ -199,7 +199,7 @@ plasma_settings = replace( stats=StatsSpec(names=("energy", "peak_intensity", "electron_density")), ionisation_field_range=(1e8, 1e11), ) -filament = prop_radial( +filament = prop_free( grid, space, argon, diff --git a/docs/guide/free-space/flux.md b/docs/guide/free-space/flux.md index a99321ad..268d31c7 100644 --- a/docs/guide/free-space/flux.md +++ b/docs/guide/free-space/flux.md @@ -372,7 +372,7 @@ settings = SimulationSettings( dz_max=2.5e-4, stats=StatsSpec(names=("energy",)), ) -run = Simulation.from_radial(grid, space, cell, [], beam, settings=settings).run() +run = Simulation.from_free(grid, space, cell, [], beam, settings=settings).run() for plane in run.planes(): flux = SpatialFlux.resolve(grid, space, cell, z=plane.z) print( @@ -395,7 +395,7 @@ A tighter budget than the path can meet refuses the run at translation: ```python strict = replace(settings, adiabatic_accuracy=AdiabaticAccuracy(reflected_energy=1e-7)) try: - Simulation.from_radial(grid, space, cell, [], beam, settings=strict).translate() + Simulation.from_free(grid, space, cell, [], beam, settings=strict).translate() except MaterialError as error: print("refused:", str(error).split(":")[0]) ``` diff --git a/docs/guide/free-space/model.md b/docs/guide/free-space/model.md index 8e4b303d..5ec289ed 100644 --- a/docs/guide/free-space/model.md +++ b/docs/guide/free-space/model.md @@ -15,7 +15,7 @@ grids: | grid | columns | assumes | medium | entry point | |---|---|---|---|---| -| {py:class}`~solprop.grid.RadialGrid` | Hankel frequencies $k_r$ of the order-zero [Hankel transform](hankel.md) | each Cartesian polarisation component cylindrically symmetric | constant, or a gas whose pressure varies along z within the [adiabatic certificate](flux.md#the-adiabatic-certificate) | `prop_radial`, `Simulation.from_radial` | +| {py:class}`~solprop.grid.RadialGrid` | Hankel frequencies $k_r$ of the order-zero [Hankel transform](hankel.md) | each Cartesian polarisation component cylindrically symmetric | constant, or a gas whose pressure varies along z within the [adiabatic certificate](flux.md#the-adiabatic-certificate) | `prop_free`, `Simulation.from_free` | | {py:class}`~solprop.grid.FreeGrid` | 2-D Fourier frequencies $(k_x, k_y)$, x fastest ([Cartesian free space](cartesian.md)) | nothing: periodic box | constant | `prop_free`, `Simulation.from_free` | | {py:class}`~solprop.grid.LineGrid` | $k_x$ alone | uniform in y ([the line](#the-line-uniform-in-y)) | constant | `prop_free`, `Simulation.from_free` | @@ -222,7 +222,7 @@ products at an angle, conical emission -- asks for a real-space collar in the gr from solprop.boundaries import SpatialAbsorber space = RadialGrid(300e-6, 230, collar_fraction=2.6) -prop_radial( +prop_free( grid, space, medium, @@ -323,9 +323,9 @@ transmission and by the change of obliquity. ## Construct and run a beam -The how-to builds and runs beams with `prop_radial` and `prop_free` +The how-to builds and runs beams with `prop_free` ([beams in bulk media](beams.md#a-beam-in-argon)), which combine construction and -execution. `Simulation.from_radial` and `Simulation.from_free` take the same arguments and +execution, on whichever grid it is given. `Simulation.from_free` takes the same arguments and separate the two: ```{code-block} python @@ -349,7 +349,7 @@ settings = SimulationSettings( dz_max=5e-4, stats=default_spec("radial"), ) -simulation = Simulation.from_radial( +simulation = Simulation.from_free( grid, space, SpatialMedium(), diff --git a/docs/guide/media/material-selection.md b/docs/guide/media/material-selection.md index 64c3b571..31d4ec04 100644 --- a/docs/guide/media/material-selection.md +++ b/docs/guide/media/material-selection.md @@ -194,7 +194,7 @@ $h_R$ are imported; a test records the $n_0^2$ ratio. ## Selecting a run's materials The drivers take the same selection as `materials=`: `prop_capillary`, -`prop_waveguide`, `prop_gnlse`, `prop_free`, `prop_radial` and `prop_planar`, the +`prop_waveguide`, `prop_gnlse`, `prop_free` and `prop_planar`, the `Simulation.from_*` constructors behind them, and `solprop.sol`'s re-exports; an omitted one is `MaterialSelection()`. What the driver resolves itself comes from optmat; what the caller built is taken as it is. @@ -214,7 +214,7 @@ optmat; what the caller built is taken as it is. - the gas, the fraction mapping and ``"air"``; the supplied modes keep their claddings. A provider's material regions are its own; a region's ``SilicaTensorResponse()`` is optmat's tensor silica bundle by default - * - ``prop_free``, ``prop_radial`` + * - ``prop_free`` - a :py:class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the one-population mixture of that gas under the selection, at the same pressure and temperature; a supplied susceptibility is kept beside optmat's equation of diff --git a/docs/guide/media/noise.md b/docs/guide/media/noise.md index a020c12e..88ed43d6 100644 --- a/docs/guide/media/noise.md +++ b/docs/guide/media/noise.md @@ -190,7 +190,7 @@ modified run, whose saved field holds no seed. See ## Reproducibility In a run, choose the model with the driver's `noise` keyword and fix the realisation with -`seed`; `prop_gnlse`, `prop_capillary`, `prop_waveguide`, `prop_radial` and `prop_free` +`seed`; `prop_gnlse`, `prop_capillary`, `prop_waveguide` and `prop_free` all take both. The keyword's default, `"default"`, resolves to `"input"` wherever the run can carry noise. Where it cannot, it resolves to none: on the hybrid planar grid and on a `prop_waveguide` run of slab modes, neither of which declares a photon measure for diff --git a/docs/guide/numerics/adams.md b/docs/guide/numerics/adams.md index bb16a5a9..afdd5c9d 100644 --- a/docs/guide/numerics/adams.md +++ b/docs/guide/numerics/adams.md @@ -4,8 +4,8 @@ from startup and rejected trials, it usually needs one new source evaluation per accepted step. This can save time when a response is expensive, while requiring more state history in memory. It is the default integrator of `prop_capillary`, -`prop_radial` and `prop_planar` and their builders; `prop_gnlse`, `prop_waveguide` and -`prop_free` default to `RungeKutta()` ([the defaults](choosing-a-stepper.md#the-defaults)). +`prop_free` on a radial grid and `prop_planar`, and their builders; `prop_gnlse`, +`prop_waveguide` and `prop_free` on a Cartesian or line grid default to `RungeKutta()` ([the defaults](choosing-a-stepper.md#the-defaults)). The default exponential formulation integrates an interpolating source polynomial against the represented linear flow. The alternative interaction formulation diff --git a/docs/guide/numerics/boundaries.md b/docs/guide/numerics/boundaries.md index 0aa0d813..570b8a77 100644 --- a/docs/guide/numerics/boundaries.md +++ b/docs/guide/numerics/boundaries.md @@ -662,7 +662,7 @@ its lengths: [transverse boundaries](../free-space/model.md#transverse-boundarie | entry point | absorber by default | |---|---| | `prop_capillary`, `prop_waveguide`, `prop_gnlse` and their `Simulation.from_*` builders | the grid's default collar, `absorber(grid)`: spectral and temporal, `boundary_N = 20`, applied in every stage | -| `prop_radial`, `prop_free`, `prop_planar` and their builders, whose settings declare none | the same temporal and spectral collar, applied once per accepted step (`absorber_scheme="split_step"`) unless planes are interpolated; the transverse boundary is the safety ramp and the monitor's edge, with a real-space collar only on request (`spatial_absorber`, SEP 40) | +| `prop_free`, `prop_planar` and their builders, whose settings declare none | the same temporal and spectral collar, applied once per accepted step (`absorber_scheme="split_step"`) unless planes are interpolated; the transverse boundary is the safety ramp and the monitor's edge, with a real-space collar only on request (`spatial_absorber`, SEP 40) | | a `Simulation` built directly with its own `SimulationSettings` | none unless `SimulationSettings(absorber=...)` is given | Every `prop_*` entry point takes `absorber=` and `absorber_scheme=`. Pass diff --git a/docs/guide/numerics/choosing-a-stepper.md b/docs/guide/numerics/choosing-a-stepper.md index d708f423..095c3f94 100644 --- a/docs/guide/numerics/choosing-a-stepper.md +++ b/docs/guide/numerics/choosing-a-stepper.md @@ -33,7 +33,7 @@ on; pass `integrator=`, `rtol=` or `atol=` to choose another. | `prop_capillary` | `Multistep()` | 1e-6 | 1e-10 √W | `"input"` | | `prop_capillary` with the TDSE response | `Multistep()` | 1e-6 | 1e-10 √W | none | | `prop_waveguide` | `RungeKutta()` | 1e-5 | 1e-10 √W | `"input"`; none for slab modes | -| `prop_radial` | `Multistep()` | 1e-5 | 0 | `"input"` | +| `prop_free` (radial) | `Multistep()` | 1e-5 | 0 | `"input"` | | `prop_free` (Cartesian, line) | `RungeKutta()` | 1e-4 | 0 | none | | `prop_planar` | `Multistep()` | 1e-5 | 0 | none (not carried) | diff --git a/docs/guide/numerics/solver.md b/docs/guide/numerics/solver.md index fb96d175..7c5250b8 100644 --- a/docs/guide/numerics/solver.md +++ b/docs/guide/numerics/solver.md @@ -6,8 +6,8 @@ problem. A small local error estimate does not establish that the full simulatio or a weak generated band is accurate. Start with the interface's default integrator, `Multistep()` on `prop_capillary`, -`prop_radial` and `prop_planar` and `RungeKutta()` on `prop_gnlse`, `prop_waveguide` and -`prop_free` ([the defaults](choosing-a-stepper.md#the-defaults)), then use +`prop_free` on a radial grid and `prop_planar`, and `RungeKutta()` on `prop_gnlse`, +`prop_waveguide` and `prop_free` on a Cartesian or line grid ([the defaults](choosing-a-stepper.md#the-defaults)), then use [stepper selection](choosing-a-stepper.md) when source cost, memory or dynamics motivate a comparison. This chapter explains `rtol`, `atol`, `dz_init` and `dz_max`, the interaction picture and the error norm. diff --git a/docs/guide/performance/tuning.md b/docs/guide/performance/tuning.md index df86d6da..8c037de0 100644 --- a/docs/guide/performance/tuning.md +++ b/docs/guide/performance/tuning.md @@ -56,8 +56,8 @@ compares the backends by problem. Each interface has its own default integrator ([the defaults](../numerics/choosing-a-stepper.md#the-defaults)): `Multistep()` on -`prop_capillary`, `prop_radial` and `prop_planar`, `RungeKutta()` on `prop_gnlse`, -`prop_waveguide` and `prop_free`. `Multistep()` evaluates the nonlinear response once +`prop_capillary`, `prop_free` on a radial grid and `prop_planar`, `RungeKutta()` on +`prop_gnlse`, `prop_waveguide` and `prop_free` on a Cartesian or line grid. `Multistep()` evaluates the nonlinear response once per step; `RungeKutta()` usually six times, in fewer, longer steps. `Multistep()` tends to gain where the response dominates a step's cost, `RungeKutta()` where it is cheap. Compare the two on a short stretch of your own problem when it matters: the call counts diff --git a/docs/guide/results/processing.md b/docs/guide/results/processing.md index 6b39dd88..df6a9bd4 100644 --- a/docs/guide/results/processing.md +++ b/docs/guide/results/processing.md @@ -149,7 +149,7 @@ settings = SimulationSettings( backend="numpy_ref", spatial_absorber=False, ) -run = Simulation.from_radial( +run = Simulation.from_free( grid, space, medium, [], pulse, settings=settings, noise=None ).run(progress="none") plane = list(run.planes())[-1] diff --git a/docs/guide/results/resuming.md b/docs/guide/results/resuming.md index 9b8d2288..31e464ec 100644 --- a/docs/guide/results/resuming.md +++ b/docs/guide/results/resuming.md @@ -131,7 +131,7 @@ A call without `seed=` draws fresh operating-system entropy each time it is buil with `resume=True` continues its own file, a realisation the constructor drew without a seed is drawn again from the seed the file recorded before the hashes are compared, and the run logs that it did. This covers the default noise of `prop_gnlse`, -`prop_capillary`, `prop_waveguide` and `prop_radial` and an explicit `noise="input"` or +`prop_capillary`, `prop_waveguide` and `prop_free` on a radial grid, and an explicit `noise="input"` or `"modified"` given without a seed. A seed the call gives, or a realisation it passes in, is never replaced: a resume that differs in it is refused, and the refusal names the seed the file was drawn from (`pass seed=17 to draw it again`). diff --git a/docs/guide/setup/model-selection.md b/docs/guide/setup/model-selection.md index 2cb61a72..fc6aaa07 100644 --- a/docs/guide/setup/model-selection.md +++ b/docs/guide/setup/model-selection.md @@ -41,7 +41,7 @@ each assumes and when it stops being valid. **Where the interfaces' keywords differ.** The keyword interfaces share their grid, pulse, noise and output keywords; these differ, by interface: -| keyword | `prop_gnlse` | `prop_capillary` | `prop_waveguide` | `prop_radial`, `prop_free`, `prop_planar` | +| keyword | `prop_gnlse` | `prop_capillary` | `prop_waveguide` | `prop_free`, `prop_planar` | |---|---|---|---|---| | `noise`, `seed` | yes | yes | yes | yes, except `prop_planar`, whose grid carries no noise model | | `rtol`, `atol`, `backend` | yes; `atol` 1e-14 √W | yes; `atol` 1e-10 √W | yes; `atol` 1e-10 √W | in the `settings` they require | diff --git a/docs/guide/setup/recipes.md b/docs/guide/setup/recipes.md index 145a5b25..c443173a 100644 --- a/docs/guide/setup/recipes.md +++ b/docs/guide/setup/recipes.md @@ -55,8 +55,8 @@ The names in `[build]` are the keywords of {py:meth}`~solprop.driver.simulation.Simulation.from_capillary`, {py:meth}`~solprop.driver.simulation.Simulation.from_gnlse`, {py:meth}`~solprop.driver.simulation.Simulation.from_waveguide`, -{py:meth}`~solprop.driver.simulation.Simulation.from_radial`, -{py:meth}`~solprop.driver.simulation.Simulation.from_free` and +{py:meth}`~solprop.driver.simulation.Simulation.from_free` (the `radial` and the `free` +routes) and {py:meth}`~solprop.driver.simulation.Simulation.from_planar`, with their meanings and defaults. A keyword left out takes solprop's default when the recipe is built, so a recipe follows solprop's improvements; the output records what ran. Keys that choose files, @@ -182,7 +182,7 @@ stats = { type = "StatsSpec", names = ["energy"] } A recipe holds the `from_*` form, so the temporal absorber and its scheme are keywords of the `settings` table, `absorber = { type = "absorber" }` built on the `grid`, and not -of `[build]`, although `prop_radial`, `prop_free` and `prop_planar` take them as +of `[build]`, although `prop_free` and `prop_planar` take them as keywords too. The settings table takes the stepping, the planes, the statistics, the absorber and the backend; `fields`, `field_planes` and `resume_history` stay in `[execution]`, and the fields a route's builder sets (the density, the diff --git a/docs/guide/setup/running.md b/docs/guide/setup/running.md index 7bbf9398..cf12dc12 100644 --- a/docs/guide/setup/running.md +++ b/docs/guide/setup/running.md @@ -29,8 +29,7 @@ There are three ways in, and they are the same machinery underneath: physics objects directly. Free-space and planar propagation have entry points of their own, -{py:func}`~solprop.driver.interface.prop_radial`, -{py:func}`~solprop.driver.interface.prop_free` and +{py:func}`~solprop.driver.interface.prop_free` (radial or Cartesian) and {py:func}`~solprop.driver.interface.prop_planar`, described on [the free-space chapter](../free-space/model.md). They use the same output, continuation, progress and resource controls linked above. diff --git a/docs/guide/setup/spatial-decompositions.md b/docs/guide/setup/spatial-decompositions.md index 9e246721..fe220178 100644 --- a/docs/guide/setup/spatial-decompositions.md +++ b/docs/guide/setup/spatial-decompositions.md @@ -57,7 +57,7 @@ How each is reached, and where it runs: | [a guide that moves along z](#guides-that-move-along-z) | √W, J | the route's own, with a `ZProfile` | both CPU, including a moving solid guide | | [rectangular hollow](#rectangular-hollow-guides) | √W, J | `prop_waveguide(modes, ..., gas=...)` | both CPU | | [planar, uniform in y](#planar-hollow-guides-uniform-in-y) | √(W/m), J/m: per metre of y | `prop_waveguide(modes, ..., gas=...)`, `ModeCollection.slab` | both CPU | -| [radial free space](#radial-free-space) | V m (spectrum), J | `prop_radial`, `Simulation.from_radial` | both CPU | +| [radial free space](#radial-free-space) | V m (spectrum), J | `prop_free`, `Simulation.from_free` on a `RadialGrid` | both CPU | | [Cartesian free space](#cartesian-free-space) | V/m, J | `prop_free`, `Simulation.from_free` | both CPU | | [line free space](#line-free-space-x-only) | V/m, J/m: per metre of y | `prop_free(grid, LineGrid(...), ...)` | both CPU | | [planar, resolved in y](#planar-guides-resolved-in-y) | √(W/m) per y frequency, J | `prop_planar`, `Simulation.from_planar` with a `PlanarGrid` | both CPU | @@ -573,7 +573,7 @@ $N = 256$ ([finite-transform error](../free-space/hankel.md#finite-transform-err power ({func}`~solprop.spatial.critical_power`) is the scalar paraxial Townes value, and does not predict where an exact-UPPE pulse collapses. -**Entry point.** `prop_radial` and `Simulation.from_radial` +**Entry point.** `prop_free` and `Simulation.from_free` on a `RadialGrid` ([construct and run a beam](../free-space/model.md#construct-and-run-a-beam)). ## Cartesian free space diff --git a/docs/guide/workflows/interactive.md b/docs/guide/workflows/interactive.md index ec7d5489..c58d813e 100644 --- a/docs/guide/workflows/interactive.md +++ b/docs/guide/workflows/interactive.md @@ -89,7 +89,7 @@ the same object as in its home module: * - group - names * - runs - - ``prop_capillary``, ``prop_gnlse``, ``prop_waveguide``, ``prop_radial``, ``prop_free``, ``prop_planar``, ``Simulation``, ``SimulationSettings``, ``Multistep``, ``RungeKutta``, ``Scan``, ``ScanPoint``, ``run_scan``, ``Local``, ``Pool``, ``Queue``, ``Slurm``, ``Batch`` + - ``prop_capillary``, ``prop_gnlse``, ``prop_waveguide``, ``prop_free``, ``prop_planar``, ``Simulation``, ``SimulationSettings``, ``Multistep``, ``RungeKutta``, ``Scan``, ``ScanPoint``, ``run_scan``, ``Local``, ``Pool``, ``Queue``, ``Slurm``, ``Batch`` * - outputs - ``open_file``, ``resume``, ``MemoryOutput``, ``HDF5Output``, ``CallbackOutput``, ``open_scan``, ``ScanOutput`` * - building blocks diff --git a/docs/introduction/tutorials/plot_03_filament.py b/docs/introduction/tutorials/plot_03_filament.py index 8cd62e50..4193b738 100644 --- a/docs/introduction/tutorials/plot_03_filament.py +++ b/docs/introduction/tutorials/plot_03_filament.py @@ -104,7 +104,7 @@ absorber=absorber(grid), ionisation_field_range=(1e8, 1e11), ) -simulation = Simulation.from_radial( +simulation = Simulation.from_free( grid, space, medium, responses, pulse, settings=settings, seed=20261027 ) run = simulation.run(progress="none") diff --git a/docs/tutorials/free_space/plot_01_radial.py b/docs/tutorials/free_space/plot_01_radial.py index 21711a8c..3415938d 100644 --- a/docs/tutorials/free_space/plot_01_radial.py +++ b/docs/tutorials/free_space/plot_01_radial.py @@ -69,7 +69,7 @@ weak_launch = weak_pulse.build(grid, space, medium=SpatialMedium()) entrance = SpatialFlux.resolve(grid, space, medium) weak_launch *= np.sqrt(2e-9 / spatial_energy(weak_launch, entrance)) -weak = Simulation.from_radial( +weak = Simulation.from_free( grid, space, medium, responses, weak_launch, settings=settings, noise=None ).run() @@ -111,7 +111,7 @@ dz_max=diagnostic_length / 12, stats=spec, ) -diagnostic = Simulation.from_radial( +diagnostic = Simulation.from_free( diagnostic_grid, space, medium, diff --git a/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py index ee00e49a..57c14a8b 100644 --- a/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py +++ b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py @@ -130,7 +130,7 @@ kerr = [KerrResponse(gamma3("Ar", wavelength=WAVELENGTH), thg=False)] gap = {} for name, responses in (("linear", []), ("Kerr", kerr)): - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, responses, beam, settings=settings, noise=None ) gap[name] = (simulation, simulation.run(progress="none")) diff --git a/docs/tutorials/free_space/plot_09_transverse_window.py b/docs/tutorials/free_space/plot_09_transverse_window.py index d952a8cc..fd80efc2 100644 --- a/docs/tutorials/free_space/plot_09_transverse_window.py +++ b/docs/tutorials/free_space/plot_09_transverse_window.py @@ -79,7 +79,7 @@ def beam(fraction=0.0, absorber=None, check=True): dz_max=LENGTH, dz_init=LENGTH * 1e-4, ) - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, diff --git a/docs/tutorials/free_space/plot_10_radial_kerr.py b/docs/tutorials/free_space/plot_10_radial_kerr.py index 1a6f6cf5..1c685faa 100644 --- a/docs/tutorials/free_space/plot_10_radial_kerr.py +++ b/docs/tutorials/free_space/plot_10_radial_kerr.py @@ -10,7 +10,7 @@ radial (Hankel) grid, below the critical power for self-focusing. Over 50 cm the Kerr lens offsets about half of the beam's diffraction: its second-moment radius grows by 0.45 %, where diffraction alone (a Rayleigh length of 3.9 m) gives 0.81 %. The run is a -plain :func:`~solprop.driver.interface.prop_radial`, every argument an object a recipe +plain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe writes as a table, and it is the call the radial route's example recipe mirrors (``Recipe.example("radial_kerr")``). @@ -22,7 +22,7 @@ import matplotlib.pyplot as plt import numpy as np -from solprop.driver import SimulationSettings, prop_radial +from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.responses import KerrResponse from solprop.physdata import gamma3 @@ -51,7 +51,7 @@ dz_max=0.05, stats=StatsSpec(names=("energy", "beam_radius")), ) -result = prop_radial( +result = prop_free( grid, space, argon, diff --git a/docs/tutorials/free_space/plot_11_radial_filament.py b/docs/tutorials/free_space/plot_11_radial_filament.py index d3e095d8..1a268685 100644 --- a/docs/tutorials/free_space/plot_11_radial_filament.py +++ b/docs/tutorials/free_space/plot_11_radial_filament.py @@ -8,7 +8,7 @@ A collimated 800 nm, 30 fs beam of 300 µm waist and 0.88 mJ in 1 bar of argon, about three times the critical power: it collapses, the plasma arrests the collapse, and a -filament forms. The run is a plain :func:`~solprop.driver.interface.prop_radial`, every +filament forms. The run is a plain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe writes as a table, and it is the call the filament's example recipe mirrors (``Recipe.example("filament")``). The lesson :doc:`/introduction/lessons/plot_03_filament` explains the physics; this page shows what @@ -22,7 +22,7 @@ import matplotlib.pyplot as plt import numpy as np -from solprop.driver import SimulationSettings, prop_radial +from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse from solprop.nonlinear.optmat_rates import OptmatRate @@ -63,7 +63,7 @@ ), ionisation_field_range=(1e8, 1e11), ) -result = prop_radial( +result = prop_free( grid, space, SpatialMedium("Ar", pressure=1.0), diff --git a/src/solprop/calibration/problems.py b/src/solprop/calibration/problems.py index 4d0d8374..904ad4b0 100644 --- a/src/solprop/calibration/problems.py +++ b/src/solprop/calibration/problems.py @@ -167,7 +167,7 @@ def _spatial(kind: str, scale: float) -> Simulation: if radial: responses.append(PlasmaResponse("Ar", OptmatRate("Ar", 800e-9))) settings = dataclasses.replace(settings, ionisation_field_range=(1e7, 1e11)) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1.5e-3, max(16, 2 * round(32 * scale))), medium, diff --git a/src/solprop/driver/__init__.py b/src/solprop/driver/__init__.py index fc5060f7..3ac6cdca 100644 --- a/src/solprop/driver/__init__.py +++ b/src/solprop/driver/__init__.py @@ -19,7 +19,6 @@ prop_free, prop_gnlse, prop_planar, - prop_radial, prop_waveguide, ) from solprop.driver.metadata import metadata_for @@ -52,7 +51,6 @@ "prop_free", "prop_gnlse", "prop_planar", - "prop_radial", "prop_waveguide", "run_problem", ] diff --git a/src/solprop/driver/defaults.py b/src/solprop/driver/defaults.py index 2dce0d8d..a8b1bc32 100644 --- a/src/solprop/driver/defaults.py +++ b/src/solprop/driver/defaults.py @@ -4,8 +4,9 @@ """The route defaults: one table, and the pure functions that resolve a default. -Each interface (``prop_gnlse``, ``prop_capillary``, ``prop_waveguide``, ``prop_radial``, -``prop_free`` and ``prop_planar``, and their ``Simulation.from_*`` constructors) steps +Each interface (``prop_gnlse``, ``prop_capillary``, ``prop_waveguide``, ``prop_free`` on +a radial or a Cartesian grid, and ``prop_planar``, and their ``Simulation.from_*`` +constructors) steps with its own integrator and tolerances, carries its own noise and applies the temporal collar in its own scheme, each qualified separately (SEP 37, SEP 21). This module holds those choices in one frozen table, :data:`ROUTE_DEFAULTS`, and resolves them in one diff --git a/src/solprop/driver/interface.py b/src/solprop/driver/interface.py index 0b00ba25..24e13d66 100644 --- a/src/solprop/driver/interface.py +++ b/src/solprop/driver/interface.py @@ -18,10 +18,12 @@ The manual page is ``docs/guide/setup/running.md``. """ +import inspect import logging +import re from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import Literal +from typing import Any, Final, Literal, cast import numpy as np from jaxtyping import Float @@ -30,6 +32,7 @@ from solprop.coupling.models import CouplingModel from solprop.driver.defaults import DEFAULT_ATOL, WAVEGUIDE_ATOL from solprop.driver.inputs import ModalLaunch +from solprop.driver.resume import _ResumeChoice from solprop.driver.settings import NoiseSetting, SimulationSettings from solprop.driver.simulation import Simulation from solprop.gasflow import VacuumEnd @@ -75,135 +78,120 @@ "prop_free", "prop_gnlse", "prop_planar", - "prop_radial", "prop_waveguide", ] logger = logging.getLogger(__name__) -def prop_radial( - grid: TimeGrid, - space: RadialGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - output: Output | Path | str | None = None, - snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, - materials: MaterialSelection | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, -) -> Output: - """Propagate a radial beam and return its saved output. +_RUN_CONTROLS: Final = ( + "output", + "snapshots", + "resume", + "progress", + "progress_period", + "tdse_archive", +) +"""The keywords of a ``prop_*`` function that are :meth:`Simulation.run`'s.""" + +_RUN_CONTROLS_DOC: Final = """\ +output : solprop.output.schema.Output or pathlib.Path or str or None, optional + Where the run goes: a handler, a path to stream an HDF5 file to, or ``None`` for + a :class:`solprop.output.memory.MemoryOutput`. +snapshots : solprop.output.SnapshotController or None, optional + Immutable local checkpoints at committed accepted steps; requires HDF5, + all fields and non-interpolated observations. +resume : bool or {'interpolated'}, optional + Continue the file at `output` when its record and this run's have one + configuration hash; otherwise it is refused, naming what changed. Only a + path can be resumed. ``"interpolated"`` also continues from a plane + interpolated inside a step + (:meth:`solprop.driver.simulation.Simulation.run`). +progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional + Where progress goes; ``None`` takes the process default: a bar on a terminal + or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). +progress_period : float, optional + Seconds between the default reporter's periodic lines, s; ``0`` leaves only the + plane and closing lines. Ignored when `progress` is given. +tdse_archive : solprop.output.TDSEArchive or None, optional + Archive every raw provider evaluation of `tdse` in a separate HDF5 file + (:meth:`~solprop.driver.simulation.Simulation.run`); refused without `tdse`. +""" +"""The run controls' parameter entries, in :data:`_RUN_CONTROLS` order.""" - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope temporal grid, in SI units. - space : solprop.grid.RadialGrid - Radial nodes and outer radius in m. - medium : solprop.spatial.SpatialMedium - Bulk material, optionally with a varying gas density along z. - responses : collections.abc.Sequence - Existing nonlinear response objects; an empty sequence gives linear flow. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Hankel spectrum in V m, shape - ``(n_r, n_pol, n_omega)``. - settings : solprop.driver.settings.SimulationSettings - Step tolerances, save planes, statistics and backend. - frame : solprop.linear.Frame or None, optional - Moving frame; None uses the responses' THG choice. - noise : {'default', None, 'input', 'modified'}, optional - One photon per spatial column, polarisation and active frequency bin. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Noise seed, required to have a noise model when given. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Handler, HDF5 path, or None for an in-memory result. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the line reporter's periodic lines, nonnegative; a bar has - its own redraw period. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: the medium's - built-in gas resolves through it (see - :meth:`~solprop.driver.simulation.Simulation.from_radial`). Supplied objects are - taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended models. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band mask; - the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` name - the default check: the default warns, then stops, when light above the - shot-noise floor reaches them; ``None`` watches nothing, for a window kept - narrow on purpose. Refused beside settings that name another check. +_SECTION = re.compile(r"^(\w[\w ]*)\n-{3,}\n", re.MULTILINE) +"""A numpydoc section header: its title, then its underline.""" - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default a wall or wrap at the grid's outer size - with the safety ramp and the boundary monitor; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. - Returns - ------- - solprop.output.schema.Output - Saved fields, metadata, statistics and step history. +def _parameters(doc: str) -> str: + """Return the entries of a cleaned docstring's ``Parameters`` section.""" + sections = list(_SECTION.finditer(doc)) + for index, section in enumerate(sections): + if section.group(1) == "Parameters": + end = sections[index + 1].start() if index + 1 < len(sections) else None + return doc[section.end() : end].strip("\n") + "\n" + msg = "the constructor's docstring has no Parameters section" + raise AssertionError(msg) + + +def _run_controls(names: Sequence[str]) -> str: + """Return the entries of the run controls among `names`, in their order.""" + entries = re.split(r"\n(?=\S)", _RUN_CONTROLS_DOC.strip("\n")) + by_name = {entry.split(" :", 1)[0]: entry for entry in entries} + return "".join(by_name[name] + "\n" for name in names if name in by_name) + + +def _propagates[**P]( + build: Callable[..., Simulation], +) -> Callable[[Callable[P, Output]], Callable[P, Output]]: + """Give a ``prop_*`` function the parameter documentation of its constructor. + + Each keyword is documented once, on ``Simulation.from_*``; the ``prop_*`` + function's own docstring has no ``Parameters`` section, and one is composed here, + before its ``Returns``: the constructor's entries, then its run controls'. """ - simulation = Simulation.from_radial( - grid, - space, - medium, - responses, - initial, - settings=settings, - frame=frame, - noise=noise, - seed=seed, - materials=materials, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - spatial_absorber=spatial_absorber, - ) - return simulation.run( - output, - snapshots=snapshots, - resume=resume, - progress=progress, - progress_period=progress_period, + + def compose(run: Callable[P, Output]) -> Callable[P, Output]: + """Write the composed docstring onto `run`.""" + own = inspect.cleandoc(run.__doc__ or "") + names = list(inspect.signature(run).parameters) + block = ( + "Parameters\n----------\n" + + _parameters(inspect.cleandoc(build.__doc__ or "")) + + _run_controls(names) + + "\n" + ) + at = own.index("Returns\n-------\n") + run.__doc__ = own[:at] + block + own[at:] + return run + + return compose + + +def _propagate(build: Callable[..., Simulation], scope: Mapping[str, object]) -> Output: + """Build a simulation from a ``prop_*`` call's keywords and run it. + + - ``scope``: the call's ``locals()``: the run controls go to :meth:`Simulation.run` + and every other keyword to `build`. + """ + keywords = dict(scope) + run = {name: keywords.pop(name) for name in _RUN_CONTROLS if name in keywords} + simulation = build(**keywords) + logger.debug( + "%s: %d modes, %d save planes, %r backend", + build.__name__, + len(simulation.modes), + len(simulation.settings.save_planes), + simulation.settings.backend, ) + # Each control is the type Simulation.run declares: prop_* declares it the same. + return simulation.run(**cast("Any", run)) -def prop_free( +@_propagates(Simulation.from_free) +def prop_free( # numpydoc ignore=PR01 grid: TimeGrid, - space: FreeGrid, + space: RadialGrid | FreeGrid, medium: SpatialMedium, responses: Sequence[object], initial: SpatialPulse | SpatialPulseSum | np.ndarray, @@ -212,120 +200,38 @@ def prop_free( frame: Frame | None = None, noise: NoiseSetting = "default", seed: int | None = None, - output: Output | Path | str | None = None, - snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, materials: MaterialSelection | None = None, absorber: Absorber | Literal[False] | None = None, absorber_scheme: AbsorberScheme | None = None, boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + output: Output | Path | str | None = None, + snapshots: SnapshotController | None = None, + resume: _ResumeChoice = False, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, ) -> Output: - """Propagate a Cartesian beam and return its saved output. + """Propagate a beam in free space and return its saved output. - A :class:`solprop.grid.LineGrid` propagates a beam uniform in y: the output's + On a :class:`solprop.grid.RadialGrid` the beam is cylindrically symmetric; on a + :class:`solprop.grid.FreeGrid` it is resolved on Cartesian axes; a + :class:`solprop.grid.LineGrid` propagates a beam uniform in y, whose output's energies and powers are then per metre of y (J/m, W/m), and processing and plotting label them so. - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope temporal grid, in SI units. - space : solprop.grid.FreeGrid - Cartesian axes and half-widths in m, or a :class:`solprop.grid.LineGrid`. - medium : solprop.spatial.SpatialMedium - Constant bulk material; varying profiles are refused. - responses : collections.abc.Sequence - Existing nonlinear response objects; an empty sequence gives linear flow. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Cartesian DFT spectrum in V/m, shape - ``(n_x*n_y, n_pol, n_omega)``. - settings : solprop.driver.settings.SimulationSettings - Step tolerances, save planes, statistics and backend. - frame : solprop.linear.Frame or None, optional - Moving frame; None uses the responses' THG choice. - noise : {'default', None, 'input', 'modified'}, optional - One photon per spatial column, polarisation and active frequency bin. - ``"default"``, the default, resolves to no noise on this interface and records - why: a Cartesian or line run is slow, and rarely about noise sensitivity - (SEP 37). Pass ``"input"`` or ``"modified"`` for a noise model. - seed : int or None, optional - Noise seed, required to have a noise model when given. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Handler, HDF5 path, or None for an in-memory result. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the line reporter's periodic lines, nonnegative; a bar has - its own redraw period. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: the medium's - built-in gas resolves through it (see - :meth:`~solprop.driver.simulation.Simulation.from_free`). Supplied objects are - taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended models. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band mask; - the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` name - the default check: the default warns, then stops, when light above the - shot-noise floor reaches them; ``None`` watches nothing, for a window kept - narrow on purpose. Refused beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default a wall or wrap at the grid's outer size - with the safety ramp and the boundary monitor; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_free`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- solprop.output.schema.Output Saved fields, metadata, statistics and step history. """ - simulation = Simulation.from_free( - grid, - space, - medium, - responses, - initial, - settings=settings, - frame=frame, - noise=noise, - seed=seed, - materials=materials, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - spatial_absorber=spatial_absorber, - ) - return simulation.run( - output, - snapshots=snapshots, - resume=resume, - progress=progress, - progress_period=progress_period, - ) + return _propagate(Simulation.from_free, locals()) -def prop_planar( +@_propagates(Simulation.from_planar) +def prop_planar( # numpydoc ignore=PR01 grid: TimeGrid, space: PlanarGrid, responses: Sequence[object], @@ -333,16 +239,16 @@ def prop_planar( *, settings: SimulationSettings, frame: Frame | None = None, - output: Output | Path | str | None = None, - snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, materials: MaterialSelection | None = None, absorber: Absorber | Literal[False] | None = None, absorber_scheme: AbsorberScheme | None = None, boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + output: Output | Path | str | None = None, + snapshots: SnapshotController | None = None, + resume: _ResumeChoice = False, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, ) -> Output: """Propagate a beam in a hollow planar guide, resolved in y, and return its output. @@ -352,90 +258,19 @@ def prop_planar( are in J and its powers in W. Constant gap and constant fill only. ``numpy_ref`` and native CPU execute the geometry. - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope temporal grid, in SI units. - space : solprop.modes.slab.PlanarGrid - The slab modes, their fill and the y axis. - responses : collections.abc.Sequence - Existing nonlinear response objects; an empty sequence gives linear flow. - initial : solprop.pulses.PlanarPulse or numpy.ndarray - The astigmatic launch, or an internal ``(p, k_y)`` spectrum in sqrt(W/m), - shape ``(n_p*n_y, 1, n_omega)``. - settings : solprop.driver.settings.SimulationSettings - Step tolerances, save planes, statistics and backend. - frame : solprop.linear.Frame or None, optional - Moving frame; None uses the responses' THG choice and the first mode. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Handler, HDF5 path, or None for an in-memory result. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default. - progress_period : float, optional - Seconds between the line reporter's periodic lines, nonnegative. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: a - built-in gas fill takes the selection's single-species susceptibility and a slab - without a declared cladding optmat's recommended fused silica (see - :meth:`~solprop.driver.simulation.Simulation.from_planar`). Supplied objects are - taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended models. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band mask; - the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` name - the default check: the default warns, then stops, when light above the - shot-noise floor reaches them; ``None`` watches nothing, for a window kept - narrow on purpose. Refused beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default a wall or wrap at the grid's outer size - with the safety ramp and the boundary monitor; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_planar`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- solprop.output.schema.Output Saved fields, metadata, statistics and step history. """ - simulation = Simulation.from_planar( - grid, - space, - responses, - initial, - settings=settings, - frame=frame, - materials=materials, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - spatial_absorber=spatial_absorber, - ) - return simulation.run( - output, - snapshots=snapshots, - resume=resume, - progress=progress, - progress_period=progress_period, - ) + return _propagate(Simulation.from_planar, locals()) -def prop_gnlse( +@_propagates(Simulation.from_gnlse) +def prop_gnlse( # numpydoc ignore=PR01 gamma: float | None, length: float, betas: Sequence[float] | np.ndarray | None = None, @@ -477,13 +312,13 @@ def prop_gnlse( absorber: Absorber | Literal[False] | None = None, absorber_scheme: AbsorberScheme | None = None, boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + backend: str = "auto", + materials: MaterialSelection | None = None, output: Output | Path | str | None = None, snapshots: SnapshotController | None = None, - backend: str = "auto", - resume: bool | Literal["interpolated"] = False, + resume: _ResumeChoice = False, progress: ProgressReporter | ProgressChoice | None = None, progress_period: float = DEFAULT_PROGRESS_PERIOD, - materials: MaterialSelection | None = None, ) -> Output: """Propagate a pulse through a GNLSE fibre and return the output it was written to. @@ -500,197 +335,8 @@ def prop_gnlse( phase is in the optics convention, as is every spectrum the output stores (``docs/dev/conventions.md`` section 2). - Parameters - ---------- - gamma : float or None - Nonlinear coefficient in 1/(W m), positive for a self-focusing medium. - length : float - Fibre length, m, positive. - betas : array_like, optional - Taylor coefficients of ``beta`` about `wavelength`, ``beta_0, beta_1, ...``, in - s^n/m. ``beta_0`` and ``beta_1`` only fix the frame and change no physics. Give - exactly one of this, `beta` or `fibre`. - wavelength : float - Reference wavelength, m, positive: the ``gamma`` mapping's reference, the centre - of the Taylor expansion, the grid's reference and the pulse's centre wavelength. - wavelength_limits : tuple of float, optional - Shortest and longest wavelength of the frequency window, m, increasing and - positive. Required unless `grid` is given. - trange : float, optional - Extent of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid, optional - An envelope grid to propagate on instead of building one from `length`, - `wavelength`, `wavelength_limits` and `trange`. This is the way to choose the - sample spacing, the oversampling factor or the transform size. A field grid is - refused: the GNLSE mapping is the envelope Kerr response. - beta : tuple or callable, optional - The propagation constant as data instead of a series: a pair of arrays - ``(omega, beta)`` in rad/s and 1/m with strictly increasing frequencies, or a - callable of ``omega`` which is sampled on the grid's active band. Either is - interpolated by a cubic spline and differentiated exactly in the spline basis - (:class:`solprop.nonlinear.gnlse.TabulatedDispersion`); evaluation outside the - data raises rather than extrapolating. - fibre : solprop.modes.protocol.Mode or solprop.modes.tabulated.IndexTable, optional - Whole dispersion and loss instead of `betas` or `beta`. A mode goes - through :meth:`solprop.nonlinear.gnlse.SpecifiedMode.from_mode`, so one - whose geometry moves along z -- a tapered mode, a moving guide's provider - channel, a ``SpecifiedMode`` with dispersion and area paths -- hands over - its dispersion and its area along z; only the fixed-step scaffold requires - a z-constant fibre. Its fill, if present, is ignored as a nonlinear - material because the GNLSE uses gamma or n2. The mode supplies only - dispersion, loss and area; use prop_capillary for its material responses. - A table's area is evaluated at `wavelength`, and so is a provider channel's, - by a fresh solve there (along the path of a moving guide); without an area - column a table requires `gamma` and uses the 1 m^2 convention. - n2 : float or str, optional - Nonlinear index in m^2/W, requiring `fibre` and ``gamma=None``. - Exactly one of `gamma` and `n2` is required. The mapping is - ``gamma = n2 omega0 / (c A_eff)``, with unit prefactor index. Under - `materials`, ``"recommended"`` takes optmat's silica bundle's total ``n2`` - (``kerr/SiO2``) at `wavelength`. - fwhm : float, optional - Intensity full width at half maximum of the input pulse, s. Give this or, for a - ``sech`` pulse, `width`. - width : float, optional - Natural width of a ``sech**2`` input, s, which is - ``fwhm / (2 ln(1 + sqrt(2)))``. Only for ``shape="sech"``. - shape : {'gauss', 'sech'}, optional - Shape of the input pulse. - energy : float, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float, optional - Peak power, W, realised after the spectral phase is applied. - phases : array_like, optional - Optics-convention spectral phase as Taylor coefficients about `wavelength`: - ``[CEP (rad), group delay (s), GDD (s**2), TOD (s**3), ...]``. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, - `peak_power` and `phases`, which must then be left unset; `wavelength` still - sets the grid's reference wavelength. - loss_db_per_m : float or None, optional - Omitted means zero loss on the coefficient routes. Refused beside - `fibre`, which carries its own loss. Power loss in dB/m; negative values - describe gain. Converted by - :func:`solprop.nonlinear.gnlse.attenuation_from_db`. - shock : bool, optional - Keep the ``omega`` dependence of the normalisation prefactor, which is the - optical shock term. Default ``True``. - raman : bool, optional - Whether a fused-silica Raman response takes `raman_fraction` of ``gamma``, the - rest staying with the Kerr term - (:func:`solprop.nonlinear.gnlse.raman_susceptibility`). Default ``False``, which - gives the whole of ``gamma`` to the Kerr term. - raman_fraction : float or None, optional - Share of ``gamma`` held back from the Kerr term, between 0 and 1. Omitted, it - is the silica bundle's own ``f_R`` with `raman` true -- 0.2188 for optmat's - default (SEP 3, D4) -- and 0 with it false. Given, it is held back whatever - `raman` is, so a pair of runs with the same fraction that switches only - `raman` isolates the delayed response. - noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry: ``'input'`` adds one photon per bin to the - input field once, ``'modified'`` keeps the realisation apart and injects it at - every stage, advanced by the unitary part of the linear operator - (``docs/guide/media/noise.md``). - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int, optional - Entropy of the generator the realisation is drawn from. Recorded in the output, - so a run is reproducible; ``None`` draws from the operating system and records - what it drew. - rtol : float or None, optional - Relative tolerance of the step controller, dimensionless and positive. - ``None`` takes :data:`~solprop.driver.defaults.DEFAULT_RTOL`, ``1e-7``. The map - from this local tolerance to the global error at the output is problem dependent - and misses by one to three orders either way, so calibrate rather than assume - (``docs/dev/stepper.md`` section 3). - atol : float, optional - Absolute tolerance, in the square root of watts, non-negative; ``1e-14`` by - default (:data:`~solprop.driver.defaults.DEFAULT_ATOL`). - dz_max : float, optional - Cap on the controller's step proposal, m; ``None`` uses half the length. - dz_init : float, optional - First step, m; ``None`` uses a ten-thousandth of the length. - schedule : solprop.stepper.schedule.FixedSchedule, optional - A fixed schedule to replay instead of stepping adaptively, which makes a run - bit-identical on re-running (``docs/dev/stepper.md`` section 8). Its edges - must contain every save plane, which - :meth:`solprop.stepper.schedule.FixedSchedule.uniform` guarantees. - integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional - The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture DOPRI5 - march, which SEP 37 measured fastest at matched accuracy on this interface - (with noise all the more). ``Multistep()`` is the low-call exponential Adams - method, one response evaluation per step, for an expensive response. - ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). - interpolate_planes : bool, optional - Observe the save planes inside accepted steps instead of landing on them - (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous - extension with ``RungeKutta()``. Default ``False``. - step_planes : int or None, optional - Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); - ``None``, the default, and ``0`` save the requested planes only. - save_planes : array_like, optional - Positions along the fibre to save at, m, increasing and positive; the plane at - ``z = 0`` is saved as well. Give this or `n_save`. - n_save : int, optional - Number of planes to store, counting the one at ``z = 0``, spread evenly over the - fibre. Default 201. - stats : solprop.stats.StatsSpec, optional - Which statistics to record at every plane; ``None`` records - :func:`solprop.stats.default_spec` of a mode-averaged run. A ``periodic`` - subset is also recorded between the planes. - polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation as well as - absorbing the field through the linear operator. Default ``False``: the taper - costs a systematic error of the size of the field's content in the collar, and - the rate absorber acts either way (``docs/guide/numerics/boundaries.md``). - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars off and keeps the hard band mask - (:func:`solprop.boundaries.no_absorber`; SEP 21). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How a temporal collar is applied (ADR-025); ``None`` is the default, inside - every stage. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, then - stops, when light above the shot-noise floor reaches them; ``None`` watches - nothing, for a window kept narrow on purpose. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Where the run goes: a handler, a path to stream an HDF5 file to, or ``None`` for - a :class:`solprop.output.memory.MemoryOutput`. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it - isn't installed or this processor is below its baseline. There is no fallback, - and import or construction errors stay visible. - :func:`solprop.backends.registry.available` lists what can be used. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the default reporter's periodic lines, s; ``0`` leaves only the - plane and closing lines. Ignored when `progress` is given. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take fused silica's nonlinear response from optmat's silica bundle under this - selection: with `raman` on, an omitted fraction is the bundle's ``f_R`` and the - response its sampled shape, and ``n2="recommended"`` is its total ``n2``. The - fibre's dispersion and a given ``gamma`` or ``n2`` are the caller's. The run - records its material manifest, with the held-back fraction - and its origin. ``None`` (the default) is ``MaterialSelection()``, optmat's - recommended models. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_gnlse`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- @@ -734,65 +380,11 @@ def prop_gnlse( >>> bool(abs(energy[-1] / energy[0] - 1.0) < 1e-8) True """ - simulation = Simulation.from_gnlse( - gamma, - length, - betas, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - beta=beta, - fibre=fibre, - n2=n2, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - loss_db_per_m=loss_db_per_m, - shock=shock, - raman=raman, - raman_fraction=raman_fraction, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - backend=backend, - materials=materials, - ) - logger.debug( - "prop_gnlse: %.6g m of fibre, gamma = %.6g 1/(W m), %d save planes, %r backend", - length, - gamma, - len(simulation.settings.save_planes), - simulation.settings.backend, - ) - return simulation.run( - output, - snapshots=snapshots, - progress=progress, - progress_period=progress_period, - resume=resume, - ) + return _propagate(Simulation.from_gnlse, locals()) -def prop_capillary( +@_propagates(Simulation.from_capillary) +def prop_capillary( # numpydoc ignore=PR01 radius: float | ZProfile, length: float, gas: Gas | str | Mapping[str, float] | Mixture, @@ -853,14 +445,14 @@ def prop_capillary( node_budget: int | None = None, backend: str = "auto", polarisation: str | float | None = None, + materials: MaterialSelection | None = None, + tdse: TDSEGasResponse | None = None, + tdse_execution: TDSEExecution | None = None, output: Output | Path | str | None = None, snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, + resume: _ResumeChoice = False, progress: ProgressReporter | ProgressChoice | None = None, progress_period: float = DEFAULT_PROGRESS_PERIOD, - materials: MaterialSelection | None = None, - tdse: TDSEGasResponse | None = None, - tdse_execution: TDSEExecution | None = None, tdse_archive: TDSEArchive | None = None, ) -> Output: """Propagate a pulse through a gas-filled hollow capillary. @@ -869,9 +461,8 @@ def prop_capillary( Kerr and plasma responses, the input pulse and the numerics are all built from keywords, and the run goes into memory or into a file. It is :meth:`solprop.driver.simulation.Simulation.from_capillary` followed by - :meth:`solprop.driver.simulation.Simulation.run`, and every keyword up to `output` - is that constructor's; see it for their meaning and - ``docs/guide/setup/running.md`` for the discussion. + :meth:`solprop.driver.simulation.Simulation.run`; ``docs/guide/setup/running.md`` + has the discussion. What differs from :func:`prop_gnlse` is the physics, not the machinery: a **field** grid rather than an envelope one, a set of modes on a shared transverse quadrature @@ -884,304 +475,8 @@ def prop_capillary( the default step caps are relative to the capillary length rather than absolute. The path is scalar: `polarisation` and the vector route are refused by name. - Parameters - ---------- - radius : float or solprop.profiles.ZProfile - Core radius, m, constant or along z. - length : float - Capillary length, m. - gas : Gas, str, Mapping or Mixture - Built-in gas, mole fractions keyed by species name, or ordered populations - carrying their own density paths. A mapping uses total pressure. - pressure : float, sequence, ZProfile or None, optional - None uses one bar for a gas or fractions. Omit pressure beside a Mixture; - every explicit pressure is refused because it already carries its paths. - Fill pressure in **bar**: a number for a constant fill, a pair - ``(p_in, p_out)`` for the steady-flow gradient of - :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the - positions and the pressures there for - :meth:`solprop.profiles.PressureGradient.from_stages`. - pressure_floor : float or None, optional - Lowest pressure the fill is allowed to reach, bar; ``None`` takes - :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest - stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true vacuum - end, which warns, because ``dp/dz`` diverges there. - Refused beside a constant pressure or a pressure profile, which have - no end to apply it to. - vacuum_end : {'choked', 'floor'} or None, optional - How a pressure gradient's outer end below its choked pressure is treated. - ``None`` (the default) means ``"choked"`` for a circular hollow guide: the fill - follows the bore and an end exhausting into vacuum takes the choked pressure - of the flow (:func:`solprop.gasflow.steady_fill`). ``"floor"`` concerns the - ends only: it keeps the declared end pressures, for a comparison with a - model that imposes them, while a taper's fill still follows the bore. See - :meth:`~solprop.driver.simulation.Simulation.from_capillary`. - wavelength : float - Reference wavelength, m, and the pulse's centre wavelength. - wavelength_limits : tuple of float or None, optional - Shortest and longest wavelength of the frequency window, m. Required unless - `grid` is given. - trange : float or None, optional - Requested width of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid or None, optional - A grid of one's own, instead of the three keywords above; the only way to - reach ``dt``, ``ffac`` or the transform size. - envelope : bool, optional - Propagate an envelope. Plasma is off by default; explicit ``plasma=True`` - selects cycle averaging of instantaneous rates. Plasma forbids THG. - fwhm : float or None, optional - Pulse duration at half maximum, s. - width : float or None, optional - Natural width of a sech pulse, s; give this or `fwhm`. - shape : {'gauss', 'sech'}, optional - Transform-limited shape of the input. - energy : float or None, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float or None, optional - Peak power, W, realised after the spectral phases. - phases : sequence of float, optional - Taylor spectral phases about `wavelength`, optics convention: CEP in rad, - group delay in s, GDD in s^2, and so on. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, - `peak_power` and `phases`, which must then be left unset; `wavelength` still - sets the grid's reference wavelength. - launch : mapping, sequence of mapping, or None, optional - Launch the input into chosen modes (SEP 41, S5): a mapping from a mode's - display label or index to its share, or to ``{"share": s, "phase": phi}`` - with the phase in radians (optics convention), as - ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, "phase": 1.57}}``; or a list of such - mappings, one for each pulse in `pulses`. The shares are energy fractions, - normalised to one for each pulse, and the launch is recorded as - ``configuration["modal_launch"]`` (see - :meth:`~solprop.driver.simulation.Simulation.from_capillary`). ``None``, the - default, puts the input in the first mode. - modes : int or sequence of Mode, optional - How many HE_1m modes to propagate, or the mode set itself. A set is checked for - orthonormality. Supplied sets may include TabulatedHollowMode or TabulatedMode - with their own tables. A supplied set carries its own geometry: `radius` must - agree with every mode's core radius along the run, or the call is refused by - name. - mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its effective - area, instead of resolving it on the transverse rule, as Luna.jl's default - does (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 - times ``P/A_eff``), and ``peak_intensity`` stays on the axis. ``False``, the - default, resolves the mode. Refused beside more than one mode and beside - `n_r`, `n_theta` or a `radial_rule`. - lattice : TubeLattice or None, optional - Tube geometry for an integer mode count, selecting Vincetti modes. - Refused with supplied modes, which carry their own geometry, and with - ``model="reduced"``. None selects ordinary capillary modes. - wall : solprop.modes.CoherentWall or None, optional - Declare the scalar **coherent** wall block: the outgoing Robin boundary - condition condensed onto the retained basis, which couples - every ``HE_1m`` channel to every other with a definite phase instead of - giving each its own Marcatili attenuation. It needs the contiguous set - ``HE_11`` to ``HE_1N`` -- or, with ``impedance="te_tm"``, one azimuthal - order -- on a constant or a varying guide, and a scalar run. It carries the - default ``"input"`` noise and refuses ``"modified"``. ``numpy_ref`` and - ``native`` execute it. - ``None``, the default, carries no wall: the modes keep their own Marcatili - loss, and a multimode taper its taper coupling (below). - taper_coupling : bool, optional - On a multimode capillary whose radius varies along z, carry the **taper - coupling** -- the connection ```` that moves power between the - ``HE_1m`` channels as the bore changes -- lossless or not. ``True``, the - default; ``False`` is the explicit opt-out to the **adiabatic** route, on which - each channel follows its local mode and exchanges no power. Refused beside - `wall`, which always carries the connection; no effect on one channel or a - constant bore. See :meth:`~solprop.driver.simulation.Simulation.from_capillary`. - model : {'full', 'reduced'}, optional - Which Marcatili model the modes use; refused beside a - supplied set unless it is the default or the modes' own. - loss : bool, float or callable, optional - Propagation loss of the modes; refused beside a supplied - set unless it is the default or the modes' own. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is the package's cited silica. Beside a - supplied set, refused unless it is the default or the modes' own. - susceptibility : GasSusceptibility or None, optional - A gas susceptibility replacing the one `gas` selects, for a comparison - against another code's refractive data. Refused for a fraction mapping - or an existing mixture, which own their species models. - temperature : float, ZProfile or None, optional - Gas temperature, K, or a path of it along z in m. Sets the density the - pressure path maps to, so it changes both the dispersion and the - nonlinearity. A path also weights a steady-flow gradient by the gas's - ``eta(T) T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's - populations independent parameters, and holds the rotational Raman - populations at one reference temperature with a - :class:`~solprop.exceptions.RamanTemperatureWarning` - (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path - is the number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture - owns its temperature: beside one, a temperature must be omitted or be the - mixture's own (the same number or path object), and any other is refused by - name. - kerr : bool, optional - Include the Kerr response. - thg : bool or None, optional - Include third-harmonic generation in the Kerr term; ``None`` takes - ``not envelope``. - plasma : bool, IonisationRate, Mapping or None, optional - Include photoionisation. ``None`` takes ``not envelope``. ``True`` selects - optmat's recommended rate model of each built-in gas (SEP 3, D1; - :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the - selection's ``ionisation`` names), for a single gas and for every component of - a mixture; a custom species needs its own rate. A rate object - is used as given; a mapping selects a rate or switch per component label, and - omitted labels are off. - preionisation : float, ZProfile, Mapping or None, optional - Initial ionised fraction in [0, 1], globally or per label: a number, or a - path along z in m giving the fraction ionised before the pulse arrives at - each position. None and omitted mapping labels retain the component's - existing declaration. - plasma_integration : {'band', 'trapezoid'} or None, optional - Form the field-grid population and free-current integrals (SEP 18): - ``'band'`` filters each integrand with taps designed for the grid, exact to - a certified 1e-5 across the active band; ``'trapezoid'`` is the plain - cumulative trapezoid, short by up to 11 % per integral at the top of the - default active band, which the frozen witness comparisons use. ``None`` takes - :class:`~solprop.nonlinear.responses.PlasmaResponse`'s default. Refused with - envelope plasma, whose currents are converted exactly, and when the plasma - is off. - raman : bool, sequence of str, Mapping or None, optional - ``None`` (the default) turns the Raman response on for every species whose - optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- leaves an atom - without one, and warns (:class:`solprop.exceptions.SolpropWarning`) for a - molecule whose response is provisional (N2O, unless the selection's ``raman`` - is ``"provisional"``) rather than leaving it out silently; with ``kerr=False`` - the default is off. ``False`` disables it; ``True`` selects the - available models of a mixture and is refused by name for a single gas without - data; explicit labels or :class:`~solprop.physdata.raman_types.MolecularOptions` - select components and terms, and a named unavailable model fails explicitly. - n_r : int or None, optional - Radial nodes of the fixed rule. ``None`` takes - :func:`solprop.driver.routes.hollow.default_radial_order` of the mode count. - n_theta : int or None, optional - Azimuthal nodes. Refused for a set of purely radial modes, which need none. - radial_rule : str, optional - The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose - embedded Gauss subset is the quadrature alarm's error estimate; - ``"gauss"``, with no alarm; or ``"field_scale"`` and - ``"field_scale_radau"``, Gaussian in the HE_11 field scale for expensive - responses, which need exactly one Marcatili HE_11 mode and have no alarm - (``docs/guide/waveguides/radial-quadrature.md``). - ionisation_field_range : tuple of float or None, optional - Field amplitudes in V/m the rate table must cover. ``None`` derives them - from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.inputs.default_field_range`. - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars, temporal and spectral, off and keeps - the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How the temporal collar is applied (ADR-025): inside every stage, the - default, or once per accepted step. The low-call Adams methods of - SEP 13 step much further with ``'split_step'``, whose first-order - splitting error stays inside the collar. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, then - stops, when light above the shot-noise floor reaches them; ``None`` watches - nothing, for a window kept narrow on purpose. - noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Entropy of the noise generator. - rtol : float or None, optional - Relative step tolerance. ``None`` takes - :data:`~solprop.driver.defaults.CAPILLARY_RTOL`, ``1e-6``. Calibrate it for a - production run (``docs/dev/stepper.md`` section 3). - atol : float, optional - Absolute step tolerance, in the square root of watts; ``1e-10`` by default - (:data:`~solprop.driver.defaults.WAVEGUIDE_ATOL`). - dz_max : float or None, optional - Cap on the step, m; ``None`` takes half the capillary. - dz_init : float or None, optional - First step, m; ``None`` takes ``length * 1e-4``. - schedule : solprop.stepper.schedule.FixedSchedule or None, optional - A fixed schedule to replay instead of stepping adaptively. - integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional - The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, - the default, takes ``Multistep()``: the low-call exponential Adams method, one - response evaluation per step. ``RungeKutta()`` is the interaction-picture - DOPRI5 march, for a cheap response or a state too large for the Adams history; - it is also faster on a long run with Raman or plasma (SEP 37). - ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). - interpolate_planes : bool, optional - Observe the save planes inside accepted steps instead of landing on them - (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous - extension with ``RungeKutta()``. Default ``False``. - step_planes : int or None, optional - Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); ``0`` saves the - requested planes only. ``None``, the default, is ``0``, or ``1`` with a - `tdse` response, which saves every accepted step (SEP 20). - save_planes : sequence of float or None, optional - Positions to save at, m. Give this or `n_save`. - n_save : int or None, optional - How many planes to save, counting ``z = 0``. - stats : solprop.stats.StatsSpec or None, optional - Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` with the profiles, for a multimode run or, - under ``mode_average=True``, a mode-averaged one without the quadrature - estimate, plus every statistic only a backend can compute. - polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation. Default - ``False``: the taper costs a systematic error of the size of the field's - content in the collar, and the rate absorber acts either way. - node_budget : int or None, optional - Bytes of node-chunk buffers the response pass may hold, an execution setting; - ``None``, the default, leaves the backend's automatic choice. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it - isn't installed or this processor is below its baseline. There is no fallback, - and import or construction errors stay visible. - polarisation : str, float or None, optional - Refused; this path is scalar. - output : solprop.output.schema.Output, pathlib.Path, str or None, optional - Where to store the run: a handler, a path to stream HDF5 to, or ``None`` for a - :class:`~solprop.output.memory.MemoryOutput`. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the default reporter's periodic lines, s; ``0`` leaves only the - plane and closing lines. Ignored when `progress` is given. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: the gas, - ``"air"`` and the default cladding resolve through it (see - :meth:`~solprop.driver.simulation.Simulation.from_capillary`). Supplied objects - are taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended - models. - tdse : solprop.nonlinear.tdse.TDSEGasResponse or None, optional - A helium TDSE response (SAE or haCC) as the complete electronic response, - with ``numpy_ref``, final-only requested output and every accepted step - saved by default (:meth:`~solprop.driver.simulation.Simulation.from_capillary`, - ``docs/guide/media/tdse.md``). - tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None, optional - Its atomic runtime resources; ``None`` beside `tdse` is the default record. - tdse_archive : solprop.output.TDSEArchive or None, optional - Archive every raw provider evaluation of `tdse` in a separate HDF5 file - (:meth:`~solprop.driver.simulation.Simulation.run`); refused without `tdse`. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_capillary`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- @@ -1227,93 +522,11 @@ def prop_capillary( >>> bool(pressure[-1] < pressure[0]) True """ - simulation = Simulation.from_capillary( - radius, - length, - gas, - pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - modes=modes, - mode_average=mode_average, - lattice=lattice, - wall=wall, - taper_coupling=taper_coupling, - model=model, - loss=loss, - cladding=cladding, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - materials=materials, - tdse=tdse, - tdse_execution=tdse_execution, - ) - logger.debug( - "prop_capillary: %.6g m of %s capillary, %d modes, %d quadrature nodes, " - "%d save planes, %r backend", - length, - gas, - len(simulation.modes), - 0 - if simulation.settings.quadrature is None - else simulation.settings.quadrature.n_nodes, - len(simulation.settings.save_planes), - simulation.settings.backend, - ) - return simulation.run( - output, - snapshots=snapshots, - progress=progress, - progress_period=progress_period, - resume=resume, - tdse_archive=tdse_archive, - ) + return _propagate(Simulation.from_capillary, locals()) -def prop_waveguide( +@_propagates(Simulation.from_waveguide) +def prop_waveguide( # numpydoc ignore=PR01 modes: Sequence[Mode], length: float, gas: Gas | str | Mapping[str, float] | Mixture | None = None, @@ -1375,12 +588,12 @@ def prop_waveguide( backend: str = "auto", polarisation: str | float | None = None, taper_coupling: bool = True, + materials: MaterialSelection | None = None, output: Output | Path | str | None = None, snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, + resume: _ResumeChoice = False, progress: ProgressReporter | ProgressChoice | None = None, progress_period: float = DEFAULT_PROGRESS_PERIOD, - materials: MaterialSelection | None = None, ) -> Output: """Propagate a pulse through a gas-filled waveguide mode set, or through a guide. @@ -1393,380 +606,12 @@ def prop_waveguide( is the **general modal route** (``docs/guide/waveguides/general-modal.md``), on a field grid, on every backend (Kokkos on CPUs and GPUs), with HDF5 output. - Parameters - ---------- - modes : sequence of solprop.modes.protocol.Mode - Gas-filled modes sharing a transverse geometry, in propagation row order. - Each is rebound to this call's fill: its own gas, pressure and temperature - (a temperature path included) are replaced, its geometry kept. Empty, or - the guide's own channels, beside `provider`. - length : float - Waveguide length, m. - gas : Gas, str, Mapping, Mixture or None - Built-in gas, mole fractions keyed by species name, or ordered populations - carrying their own density paths. A mapping uses total pressure. Required - without `provider` and refused beside one. - pressure : float, sequence, ZProfile or None, optional - None uses one bar for a gas or fractions. Omit pressure beside a Mixture; - every explicit pressure is refused because it already carries its paths. - Fill pressure in **bar**: a number for a constant fill, a pair - ``(p_in, p_out)`` for the steady-flow gradient of - :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the - positions and the pressures there for - :meth:`solprop.profiles.PressureGradient.from_stages`. - provider : solprop.modes.guide.ModalGuide or None, optional - A guide built by :meth:`solprop.modes.guide.ModalGuide.from_provider`, - which solves its own three-component E and H and whose material regions own - their responses. Its knots must span the run's whole active band. - channel : int or str or None, optional - Which channel the pulse enters, by index or display label; ``None`` takes - the first. Requires `provider`. - incident : solprop.pulses.SpatialPulse or None, optional - An incident beam carrying its own pulse, whose energy is met over the whole - beam at the entrance; it is coupled into the channels under `coupling`, - they launch the share they capture and the rest is lost, with the coupling - report recorded in the run's configuration (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Requires - `provider` and `coupling`, and is refused beside `channel` and the pulse - keywords. - coupling : solprop.coupling.models.CouplingModel or None, optional - The model and budget an `incident` beam is coupled under, a - ``vector_paraxial`` model. Requires `incident`. - kerr_form : {'real', 'analytic'}, optional - Which instantaneous cubic a general modal run's regions respond with: the - real-field cubic of the field route, the default, or the analytic-signal - form, which carries the fundamental band alone (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Requires - `provider`. - observable_rtol, observable_atol : float or None, optional - Accepted only as ``None``, which means the solver's own `rtol` and `atol` - (see :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). - Require `provider`. - pressure_floor : float or None, optional - Lowest pressure the fill is allowed to reach, bar; ``None`` takes - :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest - stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true vacuum - end, which warns, because ``dp/dz`` diverges there. - Refused beside a constant pressure or a pressure profile, which have - no end to apply it to. - vacuum_end : {'choked', 'floor'} or None, optional - How a pressure gradient's outer end below its choked pressure is treated. - ``None`` (the default) means ``"choked"`` for a circular hollow guide: the fill - follows the bore and an end exhausting into vacuum takes the choked pressure - of the flow (:func:`solprop.gasflow.steady_fill`). ``"floor"`` concerns the - ends only: it keeps the declared end pressures, for a comparison with a - model that imposes them, while a taper's fill still follows the bore. See - :meth:`~solprop.driver.simulation.Simulation.from_capillary`. - wavelength : float - Reference wavelength, m, and the pulse's centre wavelength. - wavelength_limits : tuple of float or None, optional - Shortest and longest wavelength of the frequency window, m. Required unless - `grid` is given. - trange : float or None, optional - Requested width of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid or None, optional - A grid of one's own, instead of the three keywords above; the only way to - reach ``dt``, ``ffac`` or the transform size. - envelope : bool, optional - Propagate an envelope. Plasma is off by default; explicit ``plasma=True`` - selects cycle averaging of instantaneous rates. Plasma forbids THG. - fwhm : float or None, optional - Pulse duration at half maximum, s. - width : float or None, optional - Natural width of a sech pulse, s; give this or `fwhm`. - shape : {'gauss', 'sech'}, optional - Transform-limited shape of the input. - energy : float or None, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float or None, optional - Peak power, W, realised after the spectral phases. - phases : sequence of float, optional - Taylor spectral phases about `wavelength`, optics convention: CEP in rad, - group delay in s, GDD in s^2, and so on. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, - `peak_power` and `phases`, which must then be left unset; `wavelength` still - sets the grid's reference wavelength. - launch : mapping, sequence of mapping, or None, optional - Launch the input into chosen modes or channels (SEP 41, S5): a mapping from a - display label or index, resolved as `channel` resolves it, to a share, or to - ``{"share": s, "phase": phi}`` with the phase in radians (optics convention); - or a list of such mappings, one for each pulse in `pulses`. The shares are - energy fractions on a basis whose flux metric is the identity, and a - coordinate split on a guide that declares a change of basis (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Refused beside - `channel`, `incident` and `coupling`. ``None``, the default, puts the input - in the first mode or in `channel`. - susceptibility : GasSusceptibility or None, optional - A gas susceptibility replacing the one `gas` selects, for a comparison - against another code's refractive data. Refused for a fraction mapping - or an existing mixture, which own their species models. - temperature : float, ZProfile or None, optional - Gas temperature, K, or a path of it along z in m. Sets the density the - pressure path maps to, so it changes both the dispersion and the - nonlinearity. A path also weights a steady-flow gradient by the gas's - ``eta(T) T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's - populations independent parameters, and holds the rotational Raman - populations at one reference temperature with a - :class:`~solprop.exceptions.RamanTemperatureWarning` - (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path - is the number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture - owns its temperature: beside one, a temperature must be omitted or be the - mixture's own (the same number or path object), and any other is refused by - name. - kerr : bool, optional - Include the Kerr response. - thg : bool or None, optional - Include third-harmonic generation in the Kerr term; ``None`` takes - ``not envelope``. - plasma : bool, IonisationRate, Mapping or None, optional - Include photoionisation. ``None`` takes ``not envelope``. ``True`` selects - optmat's recommended rate model of each built-in gas (SEP 3, D1; - :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the - selection's ``ionisation`` names), for a single gas and for every component of - a mixture; a custom species needs its own rate. A rate object - is used as given; a mapping selects a rate or switch per component label, and - omitted labels are off. - preionisation : float, ZProfile, Mapping or None, optional - Initial ionised fraction in [0, 1], globally or per label: a number, or a - path along z in m giving the fraction ionised before the pulse arrives at - each position. None and omitted mapping labels retain the component's - existing declaration. - plasma_integration : {'band', 'trapezoid'} or None, optional - Form the field-grid population and free-current integrals (SEP 18): - ``'band'`` filters each integrand with taps designed for the grid, exact to - a certified 1e-5 across the active band; ``'trapezoid'`` is the plain - cumulative trapezoid, short by up to 11 % per integral at the top of the - default active band, which the frozen witness comparisons use. ``None`` takes - :class:`~solprop.nonlinear.responses.PlasmaResponse`'s default. Refused with - envelope plasma, whose currents are converted exactly, and when the plasma - is off. - raman : bool, sequence of str, Mapping or None, optional - ``None`` (the default) turns the Raman response on for every species whose - optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- leaves an atom - without one, and warns (:class:`solprop.exceptions.SolpropWarning`) for a - molecule whose response is provisional (N2O, unless the selection's ``raman`` - is ``"provisional"``) rather than leaving it out silently; with ``kerr=False`` - the default is off. ``False`` disables it; ``True`` selects the - available models of a mixture and is refused by name for a single gas without - data; explicit labels or :class:`~solprop.physdata.raman_types.MolecularOptions` - select components and terms, and a named unavailable model fails explicitly. - mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its effective - area, instead of resolving it on the transverse rule, as Luna.jl's default - does (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 - times ``P/A_eff``), and ``peak_intensity`` stays on the axis. ``False``, the - default, resolves the mode. Refused beside more than one mode and beside - `n_r`, `n_theta` or a `radial_rule`. - Refused beside `provider`. - n_r : int or None, optional - Radial nodes of the fixed rule, or the x-axis nodes of a Cartesian product - or slab rule. ``None`` takes - :func:`solprop.driver.routes.hollow.default_radial_order` of the mode count, - or :func:`~solprop.modes.quadrature.cartesian_nodes` of the set's highest - transverse order where that is larger. - n_theta : int or None, optional - Azimuthal nodes, or the y-axis nodes of a Cartesian product rule, whose - default is :func:`~solprop.modes.quadrature.cartesian_nodes`. Refused for a - set of purely radial modes, which need none, and for a slab set, which is - uniform in y. - radial_rule : str, optional - The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose - embedded Gauss subset is the quadrature alarm's error estimate; - ``"gauss"``, with no alarm; or ``"field_scale"`` and - ``"field_scale_radau"``, Gaussian in the HE_11 field scale for expensive - responses, which need exactly one Marcatili HE_11 mode and have no alarm - (``docs/guide/waveguides/radial-quadrature.md``). - ionisation_field_range : tuple of float or None, optional - Field amplitudes in V/m the rate table must cover. ``None`` derives them - from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.inputs.default_field_range`. - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars, temporal and spectral, off and keeps - the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How the temporal collar is applied (ADR-025); ``None`` is the default, - inside every stage. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, then - stops, when light above the shot-noise floor reaches them; ``None`` watches - nothing, for a window kept narrow on purpose. - noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Entropy of the noise generator. - rtol : float or None, optional - Relative step tolerance. ``None`` takes - :data:`~solprop.driver.defaults.WAVEGUIDE_RTOL`, ``1e-5``. Calibrate it for a - production run (``docs/dev/stepper.md`` section 3). - atol : float, optional - Absolute step tolerance, in the square root of watts; ``1e-10`` by default - (:data:`~solprop.driver.defaults.WAVEGUIDE_ATOL`). - dz_max : float or None, optional - Cap on the step, m; ``None`` takes half the waveguide. - dz_init : float or None, optional - First step, m; ``None`` takes ``length * 1e-4``. - schedule : solprop.stepper.schedule.FixedSchedule or None, optional - A fixed schedule to replay instead of stepping adaptively. - integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional - The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture DOPRI5 - march, which SEP 37 measured fastest at matched accuracy among the methods - every route of this interface admits. ``Multistep()`` is the low-call - exponential Adams method, and ``LowStorageRungeKutta()`` replays `schedule` in - three registers. - interpolate_planes : bool, optional - Observe the save planes inside accepted steps instead of landing on them - (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous - extension with ``RungeKutta()``. Default ``False``. - step_planes : int or None, optional - Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); ``None``, the - default, and ``0`` save the requested planes only. - save_planes : sequence of float or None, optional - Positions to save at, m. Give this or `n_save`. - n_save : int or None, optional - How many planes to save, counting ``z = 0``. - stats : solprop.stats.StatsSpec or None, optional - Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` with the profiles, for a multimode run or, - under ``mode_average=True``, a mode-averaged one without the quadrature - estimate, plus every statistic only a backend can compute. - polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation. Default - ``False``: the taper costs a systematic error of the size of the field's - content in the collar, and the rate absorber acts either way. - node_budget : int or None, optional - Bytes of node-chunk buffers the response pass may hold, an execution setting; - ``None``, the default, leaves the backend's automatic choice. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it - isn't installed or this processor is below its baseline. There is no fallback, - and import or construction errors stay visible. - polarisation : str, float or None, optional - Refused; this path is scalar. - taper_coupling : bool, optional - Carry the taper coupling of a supplied capillary set on a varying radius; - ``False`` is the opt-out to the adiabatic route, refused beside `provider` - (see :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). - output : solprop.output.schema.Output, pathlib.Path, str or None, optional - Where to store the run: a handler, a path to stream HDF5 to, or ``None`` for a - :class:`~solprop.output.memory.MemoryOutput`. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the default reporter's periodic lines, s; ``0`` leaves only the - plane and closing lines. Ignored when `progress` is given. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: the gas and - ``"air"`` resolve through it (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Supplied objects - are taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended - models. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_waveguide`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- solprop.output.schema.Output Saved fields, metadata, statistics and step history. """ - simulation = Simulation.from_waveguide( - modes, - length, - gas, - pressure, - provider=provider, - channel=channel, - incident=incident, - coupling=coupling, - kerr_form=kerr_form, - observable_rtol=observable_rtol, - observable_atol=observable_atol, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - mode_average=mode_average, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - taper_coupling=taper_coupling, - materials=materials, - ) - logger.debug( - "prop_waveguide: %.6g m of %s waveguide, %d modes, %d quadrature nodes, " - "%d save planes, %r backend", - length, - gas, - len(simulation.modes), - 0 - if simulation.settings.quadrature is None - else simulation.settings.quadrature.n_nodes, - len(simulation.settings.save_planes), - simulation.settings.backend, - ) - return simulation.run( - output, - snapshots=snapshots, - progress=progress, - progress_period=progress_period, - resume=resume, - ) + return _propagate(Simulation.from_waveguide, locals()) diff --git a/src/solprop/driver/settings.py b/src/solprop/driver/settings.py index 2ca41a2c..55675373 100644 --- a/src/solprop/driver/settings.py +++ b/src/solprop/driver/settings.py @@ -144,11 +144,12 @@ class SimulationSettings(Displayed): when an interface builds the run: the :data:`~solprop.driver.defaults.SPATIAL_RTOL` and :data:`~solprop.driver.defaults.SPATIAL_ATOL` of - :meth:`~solprop.driver.simulation.Simulation.from_radial` and - :meth:`~solprop.driver.simulation.Simulation.from_planar`, the + :meth:`~solprop.driver.simulation.Simulation.from_free` on a radial grid and + of :meth:`~solprop.driver.simulation.Simulation.from_planar`, the :data:`~solprop.driver.defaults.FREE_RTOL` and :data:`~solprop.driver.defaults.FREE_ATOL` of - :meth:`~solprop.driver.simulation.Simulation.from_free`. An explicit controller + :meth:`~solprop.driver.simulation.Simulation.from_free` on a Cartesian or line + grid. An explicit controller always wins, and a default copied into another route takes that route's default. The other fields are keyword-only. save_planes : numpy.ndarray diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index b2787f9b..5274a9f8 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -445,7 +445,7 @@ def __init__( self.settings = settings if settings.controller is None: msg = ( - "settings.controller is None: Simulation.from_radial and " + "settings.controller is None: Simulation.from_free and " "Simulation.from_planar supply their interface's default tolerance, " "but a Simulation built directly needs a PIController" ) @@ -1999,119 +1999,12 @@ def _rebound_noise( redraw = _spatial_draw(noise.model, self, medium) return redraw(noise.seed), redraw - @classmethod - @_one_material_context - def from_radial( - cls, - grid: TimeGrid, - space: RadialGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - materials: MaterialSelection | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, - ) -> "Simulation": - """Build a radial propagation using physical fields and the scalar QDHT. - - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope time grid, in SI units. - space : solprop.grid.RadialGrid - Radial nodes, outer radius and spatial collar. - medium : solprop.spatial.SpatialMedium - Homogeneous transverse material; gas density may vary along z. - responses : collections.abc.Sequence - Existing Kerr, plasma or explicitly declared Raman response objects. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Hankel spectrum in V m, with shape - ``(n_r, n_pol, n_omega)``. - settings : SimulationSettings - Step tolerances, positive save positions in m, statistics and backend. - Absolute tolerance has the input spectrum's units, V m. - frame : solprop.linear.Frame or None, optional - Moving frame. None uses the declared responses' THG choice. - noise : {'default', None, 'input', 'modified'}, optional - One photon per transverse column, polarisation and active bin. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Random generator seed; None draws and records system entropy. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the medium's gas from optmat under this selection: a - :class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the - one-population :class:`~solprop.physdata.mixtures.Mixture` of that gas - under the selection at the same pressure and temperature (a supplied - susceptibility is kept). A mixture, glass or constant index is taken as - it is. An optmat rate's energy per event is bound to the input's peak - field (SEP 3, Q14); every response, a - :class:`~solprop.nonlinear.responses.KerrResponse` included, is the - caller's (``gamma3("Ar", wavelength=...)`` is optmat's coefficient at a - carrier). The run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band - mask; the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` - name the default check: the default warns, then stops, when light above - the shot-noise floor reaches them; ``None`` watches nothing. Refused - beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default the safety ramp alone; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. - - Returns - ------- - Simulation - Cached translation and the ordinary resumable execution interface. - - Raises - ------ - DriverError - If a noise seed is given without noise or the input shape is invalid. - """ - materials = MaterialSelection() if materials is None else materials - return _from_spatial( - cls, - grid, - space, - _selected_medium(medium, materials), - responses, - initial, - settings=settings.with_boundaries( - grid, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - spatial_absorber=spatial_absorber, - ), - frame=frame, - noise=noise, - seed=seed, - materials=materials, - ) - @classmethod @_one_material_context def from_free( cls, grid: TimeGrid, - space: FreeGrid, + space: RadialGrid | FreeGrid, medium: SpatialMedium, responses: Sequence[object], initial: SpatialPulse | SpatialPulseSum | np.ndarray, @@ -2126,36 +2019,43 @@ def from_free( boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, spatial_absorber: SpatialAbsorber | Literal[False] | None = None, ) -> "Simulation": - """Build a Cartesian propagation with constant media and transverse FFTs. + """Build a free-space propagation of physical fields, radial or Cartesian. - A :class:`solprop.grid.LineGrid` runs the same transform with one y column: - the beam is uniform in y, the run records ``transverse_measure = + The transverse grid chooses the transform: a :class:`solprop.grid.RadialGrid` + propagates a cylindrically symmetric beam with the scalar QDHT, a + :class:`solprop.grid.FreeGrid` a beam on Cartesian axes with transverse FFTs, + and a :class:`solprop.grid.LineGrid` the same transform with one y column: the + beam is uniform in y, the run records ``transverse_measure = "per_unit_width"``, and its energies and powers are per metre of y. Parameters ---------- grid : solprop.grid.TimeGrid Field or envelope time grid, in SI units. - space : solprop.grid.FreeGrid - Cartesian axes, area measure and spatial collar, or a - :class:`solprop.grid.LineGrid`. + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or solprop.grid.LineGrid + Radial nodes, outer radius and spatial collar; or Cartesian axes, area + measure and spatial collar. medium : solprop.spatial.SpatialMedium - Constant bulk material; varying profiles are refused. + Homogeneous transverse material. Its gas density may vary along z on a + radial grid; on a Cartesian or line grid it must be constant. responses : collections.abc.Sequence Existing Kerr, plasma or explicitly declared Raman response objects. initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Cartesian DFT spectrum in V/m, with shape - ``(n_x*n_y, n_pol, n_omega)``. + Beam specification or internal spectrum of shape + ``(n_columns, n_pol, n_omega)``: a Hankel spectrum in V m, ``n_r`` + columns, on a radial grid; a Cartesian DFT spectrum in V/m, ``n_x*n_y`` + columns, otherwise. settings : SimulationSettings Step tolerances, positive save positions in m, statistics and backend. - Absolute tolerance has the input spectrum's units, V/m. + Absolute tolerance has the input spectrum's units. frame : solprop.linear.Frame or None, optional Moving frame. None uses the declared responses' THG choice. noise : {'default', None, 'input', 'modified'}, optional One photon per transverse column, polarisation and active bin. - ``"default"``, the default, resolves to no noise on a Cartesian or line - grid and records why (:data:`~solprop.driver.defaults.FREE_NOISE`, SEP - 37); pass ``"input"`` or ``"modified"`` for a noise model. + ``"default"``, the default, resolves to ``"input"`` on a radial grid; on a + Cartesian or line grid it resolves to no noise and records why + (:data:`~solprop.driver.defaults.FREE_NOISE`, SEP 37), so pass ``"input"`` + or ``"modified"`` there for a noise model. seed : int or None, optional Random generator seed; None draws and records system entropy. materials : solprop.physdata.selection.MaterialSelection or None, optional @@ -2181,7 +2081,6 @@ def from_free( name the default check: the default warns, then stops, when light above the shot-noise floor reaches them; ``None`` watches nothing. Refused beside settings that name another check. - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional The transverse boundary (SEP 40), when `settings` declare none: ``None`` keeps the settings' own, by default the safety ramp alone; a @@ -2196,11 +2095,12 @@ def from_free( Raises ------ DriverError - If a noise seed is given without noise or the input shape is invalid. + If a noise seed is given without noise, the input shape is invalid, or the + density varies along z on a Cartesian or line grid. """ materials = MaterialSelection() if materials is None else materials medium = _selected_medium(medium, materials) - if not medium.density.is_constant: + if not isinstance(space, RadialGrid) and not medium.density.is_constant: msg = ( "Cartesian free propagation requires constant media; " "varying density profiles are unsupported" @@ -2412,67 +2312,78 @@ def from_gnlse( ) -> "Simulation": """Build a simulation of a GNLSE fibre from its coefficients. - The constructor :func:`solprop.driver.interface.prop_gnlse` calls; its docstring - documents every keyword, and this method takes them unchanged. The GNLSE is not - a separate solver path: it is the mode-averaged envelope problem with a - dispersion-specified mode, ``density = 1``, ``prefactor_index = 1`` and the - shock term supplied by the prefactor's ``omega`` dependence - (``docs/guide/waveguides/gnlse.md``). + The constructor :func:`solprop.driver.interface.prop_gnlse` calls, with these + keywords and its run controls. The GNLSE is not a separate solver path: it is + the mode-averaged envelope problem with a dispersion-specified mode, + ``density = 1``, ``prefactor_index = 1`` and the shock term supplied by the + prefactor's ``omega`` dependence (``docs/guide/waveguides/gnlse.md``). Parameters ---------- gamma : float or None - Nonlinear coefficient in 1/(W m). + Nonlinear coefficient in 1/(W m), positive for a self-focusing medium. length : float Fibre length, m, positive. betas : array_like, optional - Taylor coefficients of ``beta`` about `wavelength`, ``beta_0, beta_1, ...`` - in s^n/m. Give exactly one of this, `beta` or `fibre`. + Taylor coefficients of ``beta`` about `wavelength`, ``beta_0, beta_1, ...``, + in s^n/m. ``beta_0`` and ``beta_1`` only fix the frame and change no + physics. Give exactly one of this, `beta` or `fibre`. wavelength : float - Reference wavelength, m: the ``gamma`` mapping's, the Taylor expansion's, - the grid's and the pulse's centre. + Reference wavelength, m, positive: the ``gamma`` mapping's reference, the + centre of the Taylor expansion, the grid's reference and the pulse's centre + wavelength. wavelength_limits : tuple of float, optional - Shortest and longest wavelength of the frequency window, m. Required unless - `grid` is given. + Shortest and longest wavelength of the frequency window, m, increasing and + positive. Required unless `grid` is given. trange : float, optional Extent of the time window, s. Required unless `grid` is given. grid : solprop.grid.TimeGrid, optional - An envelope grid to use instead of building one. A field grid is refused. + An envelope grid to propagate on instead of building one from `length`, + `wavelength`, `wavelength_limits` and `trange`. This is the way to choose + the sample spacing, the oversampling factor or the transform size. A field + grid is refused: the GNLSE mapping is the envelope Kerr response. beta : tuple or callable, optional - The propagation constant as data: a pair of arrays ``(omega, beta)`` in - rad/s and 1/m, or a callable of ``omega`` sampled onto the grid's active - band. Both go through :class:`solprop.nonlinear.gnlse.TabulatedDispersion`. + The propagation constant as data instead of a series: a pair of arrays + ``(omega, beta)`` in rad/s and 1/m with strictly increasing frequencies, or + a callable of ``omega`` which is sampled on the grid's active band. Either + is interpolated by a cubic spline and differentiated exactly in the spline + basis (:class:`solprop.nonlinear.gnlse.TabulatedDispersion`); evaluation + outside the data raises rather than extrapolating. fibre : Mode or IndexTable, optional Whole dispersion and loss instead of `betas` or `beta`. A mode goes through :meth:`solprop.nonlinear.gnlse.SpecifiedMode.from_mode`, so one - whose geometry moves along z -- a tapered mode, a moving guide's - provider channel, a ``SpecifiedMode`` with dispersion and area paths -- - hands over its dispersion and its area along z; only the fixed-step - scaffold requires a z-constant fibre. Its fill is ignored as a - nonlinear material: the GNLSE uses gamma or n2, retaining the mode only - for dispersion, loss and area. A table's area is evaluated at - `wavelength`, and so is a provider channel's, by a fresh solve there (along - the path of a moving guide); without an area column a table requires - `gamma` and uses the 1 m^2 convention. + whose geometry moves along z -- a tapered mode, a moving guide's provider + channel, a ``SpecifiedMode`` with dispersion and area paths -- hands over + its dispersion and its area along z; only the fixed-step scaffold requires + a z-constant fibre. Its fill, if present, is ignored as a nonlinear + material because the GNLSE uses gamma or n2. The mode supplies only + dispersion, loss and area; use + :meth:`~solprop.driver.simulation.Simulation.from_capillary` for its + material responses. + A table's area is evaluated at `wavelength`, and so is a provider channel's, + by a fresh solve there (along the path of a moving guide); without an area + column a table requires `gamma` and uses the 1 m^2 convention. n2 : float or str, optional Nonlinear index in m^2/W, requiring `fibre` and ``gamma=None``. Exactly one of `gamma` and `n2` is required. The mapping is ``gamma = n2 omega0 / (c A_eff)``, with unit prefactor index. Under - `materials`, ``"recommended"`` takes optmat's silica bundle's total - ``n2`` at `wavelength`. + `materials`, ``"recommended"`` takes optmat's silica bundle's total ``n2`` + (``kerr/SiO2``) at `wavelength`. fwhm : float, optional - Intensity full width at half maximum of the input, s. + Intensity full width at half maximum of the input pulse, s. Give this or, + for a ``sech`` pulse, `width`. width : float, optional - Natural width of a ``sech**2`` input, s. Give this or `fwhm`. + Natural width of a ``sech**2`` input, s, which is + ``fwhm / (2 ln(1 + sqrt(2)))``. Only for ``shape="sech"``. shape : {'gauss', 'sech'}, optional - Shape of the input. + Shape of the input pulse. energy : float, optional Pulse energy, J. Give this or `peak_power`. peak_power : float, optional - Peak power, W. + Peak power, W, realised after the spectral phase is applied. phases : array_like, optional - Optics-convention spectral phase about `wavelength`: CEP in rad, then group - delay, GDD, TOD, ... in s^n. + Optics-convention spectral phase as Taylor coefficients about `wavelength`: + ``[CEP (rad), group delay (s), GDD (s**2), TOD (s**3), ...]``. pulses : sequence of solprop.pulses.Pulse or None, optional Several pulses launched together, for two-colour and multi-colour inputs: each a :mod:`solprop.pulses` object with its own wavelength, @@ -2483,42 +2394,60 @@ def from_gnlse( sets the grid's reference wavelength. loss_db_per_m : float or None, optional Omitted means zero loss on the coefficient routes. Refused beside - `fibre`, which carries its own loss. Power loss in dB/m, converted by + `fibre`, which carries its own loss. Power loss in dB/m; negative values + describe gain. Converted by :func:`solprop.nonlinear.gnlse.attenuation_from_db`. shock : bool, optional - Keep the prefactor's ``omega`` dependence, which is the shock term. + Keep the ``omega`` dependence of the normalisation prefactor, which is the + optical shock term. Default ``True``. raman : bool, optional - Whether a fused-silica Raman response takes `raman_fraction` of ``gamma``. + Whether a fused-silica Raman response takes `raman_fraction` of ``gamma``, + the rest staying with the Kerr term + (:func:`solprop.nonlinear.gnlse.raman_susceptibility`). Default ``False``, + which gives the whole of ``gamma`` to the Kerr term. raman_fraction : float or None, optional Share of ``gamma`` held back from the Kerr term, between 0 and 1. Omitted, - the silica bundle's ``f_R`` with `raman` true and 0 with it false; given, - held back either way. + it is the silica bundle's own ``f_R`` with `raman` true -- 0.2188 for + optmat's default (SEP 3, D4) -- and 0 with it false. Given, it is held back + whatever `raman` is, so a pair of runs with the same fraction that switches + only `raman` isolates the delayed response. noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry (``docs/guide/media/noise.md``). - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). + Which shot-noise model to carry: ``'input'`` adds one photon per bin to the + input field once, ``'modified'`` keeps the realisation apart and injects it + at every stage, advanced by the unitary part of the linear operator + (``docs/guide/media/noise.md``). ``"default"``, the default, resolves to + ``"input"`` wherever the run can carry noise, and records the resolution + (SEP 37). seed : int, optional - Entropy of the generator the realisation is drawn from, recorded in the - output so a run is reproducible. + Entropy of the generator the realisation is drawn from. Recorded in the + output, so a run is reproducible; ``None`` draws from the operating system + and records what it drew. rtol : float or None, optional - Relative tolerance of the step controller. + Relative tolerance of the step controller, dimensionless and positive. + ``None`` takes :data:`~solprop.driver.defaults.DEFAULT_RTOL`, ``1e-7``. The + map from this local tolerance to the global error at the output is problem + dependent and misses by one to three orders either way, so calibrate rather + than assume (``docs/dev/stepper.md`` section 3). atol : float, optional - Absolute tolerance, in the square root of watts. + Absolute tolerance, in the square root of watts, non-negative; ``1e-14`` by + default (:data:`~solprop.driver.defaults.DEFAULT_ATOL`). dz_max : float, optional - Cap on the controller's proposal, m; ``None`` uses half the length. + Cap on the controller's step proposal, m; ``None`` uses half the length. dz_init : float, optional First step, m; ``None`` uses a ten-thousandth of the length. schedule : solprop.stepper.schedule.FixedSchedule, optional - A fixed schedule to replay instead of stepping adaptively - (``docs/dev/stepper.md`` section 8). + A fixed schedule to replay instead of stepping adaptively, which makes a run + bit-identical on re-running (``docs/dev/stepper.md`` section 8). Its edges + must contain every save plane, which + :meth:`solprop.stepper.schedule.FixedSchedule.uniform` guarantees. integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional The method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture - DOPRI5 march, which SEP 37 measured fastest at matched accuracy on the - GNLSE. ``Multistep()`` is the low-call exponential Adams method, one - response evaluation per step. ``LowStorageRungeKutta()`` replays - `schedule` in three registers (SEP 24). + (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, + takes ``RungeKutta()``, the interaction-picture DOPRI5 march, which SEP 37 + measured fastest at matched accuracy on the GNLSE (with noise all the + more). ``Multistep()`` is the low-call exponential Adams method, one + response evaluation per step, for an expensive response. + ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). interpolate_planes : bool, optional Observe the save planes inside accepted steps instead of landing on them (SEP 14): through the Adams march's own polynomial, or DOPRI5's @@ -2528,17 +2457,21 @@ def from_gnlse( march's own positions, beside the requested planes (SEP 14); ``None``, the default, and ``0`` save the requested planes only. save_planes : array_like, optional - Positions to save at, m. Give this or `n_save`. + Positions along the fibre to save at, m, increasing and positive; the plane + at ``z = 0`` is saved as well. Give this or `n_save`. n_save : int, optional Number of planes to store, counting the one at ``z = 0``, spread evenly over the fibre. Default 201. stats : solprop.stats.StatsSpec, optional - Which statistics to record; ``None`` records - :func:`solprop.stats.default_spec` of a mode-averaged run. + Which statistics to record at every plane; ``None`` records + :func:`solprop.stats.default_spec` of a mode-averaged run. A ``periodic`` + subset is also recorded between the planes. polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation. Default - ``False``: the taper costs a systematic error of the size of the field's - content in the collar, and the rate absorber acts either way. + Whether the spectral collar tapers the nonlinear polarisation as well as + absorbing the field through the linear operator. Default ``False``: the + taper costs a systematic error of the size of the field's content in the + collar, and the rate absorber acts either way + (``docs/guide/numerics/boundaries.md``). absorber : solprop.boundaries.Absorber, False or None, optional The collar rates; ``None`` builds the default collar of the grid (:func:`~solprop.driver.settings.default_collar`), and ``False`` switches @@ -2555,14 +2488,15 @@ def from_gnlse( Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it isn't installed or this processor is below its baseline. There is no fallback, and import or construction errors stay visible. + :func:`solprop.backends.registry.available` lists what can be used. materials : solprop.physdata.selection.MaterialSelection or None, optional - Take fused silica's nonlinear response from optmat's silica bundle - (:class:`~solprop.physdata.glasses.SilicaBundle`): with `raman` on, an - omitted fraction is the bundle's ``f_R`` and the response its sampled - shape; ``n2="recommended"`` is its total ``n2``. The fibre's dispersion, - area and a given ``gamma`` or ``n2`` are the caller's. The run records - its manifest, the held-back fraction and its origin - included. ``None`` (the default) is ``MaterialSelection()``. + Take fused silica's nonlinear response from optmat's silica bundle under + this selection: with `raman` on, an omitted fraction is the bundle's ``f_R`` + and the response its sampled shape, and ``n2="recommended"`` is its total + ``n2``. The fibre's dispersion and a given ``gamma`` or ``n2`` are the + caller's. The run records its material manifest, with the held-back fraction + and its origin. ``None`` (the default) is ``MaterialSelection()``, optmat's + recommended models. Returns ------- @@ -2840,13 +2774,13 @@ def from_capillary( every mode's own, or the call is refused by name rather than the argument ignored. mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its - effective area, instead of resolving it on the radial rule - (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is - 1.768 times ``P/A_eff``), and ``peak_intensity`` stays on the axis. - ``False``, the default, resolves the mode. Refused beside more than one - mode and beside `n_r`, `n_theta` or a `radial_rule`. + Propagate the one mode **mode-averaged**, as a uniform disc of its effective + area, instead of resolving it on the transverse rule, as Luna.jl's default + does (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the + area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 + times ``P/A_eff``), and ``peak_intensity`` stays on the axis. ``False``, the + default, resolves the mode. Refused beside more than one mode and beside + `n_r`, `n_theta` or a `radial_rule`. lattice : TubeLattice or None, optional Tube geometry for an integer mode count, selecting Vincetti modes. Refused with supplied modes, which carry their own geometry, and with @@ -2956,8 +2890,8 @@ def from_capillary( :func:`solprop.driver.inputs.default_field_range`. absorber : solprop.boundaries.Absorber, False or None, optional The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars off and keeps the hard band mask - (:func:`solprop.boundaries.no_absorber`; SEP 21). + ``False`` switches both soft collars, temporal and spectral, off and keeps + the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). absorber_scheme : {'nonlinear', 'split_step'} or None, optional How the temporal collar is applied (ADR-025): inside every stage, the default, or once per accepted step. The low-call Adams methods of @@ -2978,7 +2912,8 @@ def from_capillary( :data:`~solprop.driver.defaults.CAPILLARY_RTOL`, the capillary default. Calibrate it for a production run (``docs/dev/stepper.md`` section 3). atol : float, optional - Absolute step tolerance, in the square root of watts. + Absolute step tolerance, in the square root of watts; ``1e-10`` by default + (:data:`~solprop.driver.defaults.WAVEGUIDE_ATOL`). dz_max : float or None, optional Cap on the step, m; ``None`` takes half the capillary. dz_init : float or None, optional @@ -2987,13 +2922,12 @@ def from_capillary( A fixed schedule to replay instead of stepping adaptively. integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional The method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``Multistep()``: the low-call exponential - Adams method, one response evaluation per step. ``RungeKutta()`` is the - interaction-picture DOPRI5 march, for a cheap response or a state too - large for the Adams history, and faster on a long run with Raman or - plasma (SEP 37). ``LowStorageRungeKutta()`` replays - `schedule` in three registers (SEP 24). + (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, + takes ``Multistep()``: the low-call exponential Adams method, one response + evaluation per step. ``RungeKutta()`` is the interaction-picture DOPRI5 + march, for a cheap response or a state too large for the Adams history; it + is also faster on a long run with Raman or plasma (SEP 37). + ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). interpolate_planes : bool, optional Observe the save planes inside accepted steps instead of landing on them (SEP 14): through the Adams march's own polynomial, or DOPRI5's @@ -3010,9 +2944,9 @@ def from_capillary( How many planes to save, counting ``z = 0``. stats : solprop.stats.StatsSpec or None, optional Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` for a multimode run with the profiles, - or for a mode-averaged one under `mode_average`, plus every statistic - only a backend can compute. + :func:`solprop.stats.default_spec` with the profiles, for a multimode run + or, under ``mode_average=True``, a mode-averaged one without the quadrature + estimate, plus every statistic only a backend can compute. polarisation_taper : bool, optional Whether the spectral collar tapers the nonlinear polarisation as well as absorbing the field through the linear operator. Default ``False``: the @@ -3394,13 +3328,13 @@ def from_waveguide( :class:`~solprop.physdata.raman_types.MolecularOptions` select components and terms, and a named unavailable model fails explicitly. mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its - effective area, instead of resolving it on the transverse rule - (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is - 1.768 times ``P/A_eff``), and ``peak_intensity`` stays on the axis. - ``False``, the default, resolves the mode. Refused beside more than one - mode and beside `n_r`, `n_theta` or a `radial_rule`. + Propagate the one mode **mode-averaged**, as a uniform disc of its effective + area, instead of resolving it on the transverse rule, as Luna.jl's default + does (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the + area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 + times ``P/A_eff``), and ``peak_intensity`` stays on the axis. ``False``, the + default, resolves the mode. Refused beside more than one mode and beside + `n_r`, `n_theta` or a `radial_rule`. Refused beside `provider`. n_r : int or None, optional Radial nodes of the fixed rule, or the x-axis nodes of a Cartesian product @@ -3426,8 +3360,8 @@ def from_waveguide( :func:`solprop.driver.inputs.default_field_range`. absorber : solprop.boundaries.Absorber, False or None, optional The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars off and keeps the hard band mask - (:func:`solprop.boundaries.no_absorber`; SEP 21). + ``False`` switches both soft collars, temporal and spectral, off and keeps + the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). absorber_scheme : {'nonlinear', 'split_step'} or None, optional How the temporal collar is applied (ADR-025); ``None`` is the default, inside every stage. @@ -3447,7 +3381,8 @@ def from_waveguide( default. Calibrate it for a production run (``docs/dev/stepper.md`` section 3). atol : float, optional - Absolute step tolerance, in the square root of watts. + Absolute step tolerance, in the square root of watts; ``1e-10`` by default + (:data:`~solprop.driver.defaults.WAVEGUIDE_ATOL`). dz_max : float or None, optional Cap on the step, m; ``None`` takes half the waveguide. dz_init : float or None, optional @@ -3456,10 +3391,12 @@ def from_waveguide( A fixed schedule to replay instead of stepping adaptively. integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional The method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture - DOPRI5 march (SEP 37). ``Multistep()`` is the low-call exponential Adams - method. ``LowStorageRungeKutta()`` replays `schedule` in three registers. + (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, + takes ``RungeKutta()``, the interaction-picture DOPRI5 march, which SEP 37 + measured fastest at matched accuracy among the methods every route of this + constructor admits. ``Multistep()`` is the low-call exponential Adams + method, and ``LowStorageRungeKutta()`` replays `schedule` in three + registers. interpolate_planes : bool, optional Observe the save planes inside accepted steps instead of landing on them (SEP 14): through the Adams march's own polynomial, or DOPRI5's @@ -3474,9 +3411,9 @@ def from_waveguide( How many planes to save, counting ``z = 0``. stats : solprop.stats.StatsSpec or None, optional Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` for a multimode run with the profiles, - or for a mode-averaged one under `mode_average`, plus every statistic - only a backend can compute. + :func:`solprop.stats.default_spec` with the profiles, for a multimode run + or, under ``mode_average=True``, a mode-averaged one without the quadrature + estimate, plus every statistic only a backend can compute. polarisation_taper : bool, optional Whether the spectral collar tapers the nonlinear polarisation as well as absorbing the field through the linear operator. Default ``False``: the @@ -3494,9 +3431,9 @@ def from_waveguide( Refused; this path is scalar. taper_coupling : bool, optional Carry the taper coupling of a supplied capillary set on a varying radius, as - :meth:`from_capillary` does; ``False`` is the opt-out to the adiabatic - route. Refused as ``False`` beside a `provider`, whose moving guide carries - its own connection. + :meth:`~solprop.driver.simulation.Simulation.from_capillary` does; ``False`` + is the opt-out to the adiabatic route. Refused as ``False`` beside a + `provider`, whose moving guide carries its own connection. materials : solprop.physdata.selection.MaterialSelection or None, optional Take the fill from optmat under this selection: a gas or fraction mapping of built-in gases, ``"air"`` and ``Gas.Air`` resolve through diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py index 0241db10..8370eddb 100644 --- a/src/solprop/processing/planes.py +++ b/src/solprop/processing/planes.py @@ -212,7 +212,7 @@ def fluence_map( >>> from solprop.pulses import GaussianPulse, SpatialPulse >>> from solprop.spatial import SpatialMedium >>> from solprop.stepper import PIController - >>> simulation = Simulation.from_radial( + >>> simulation = Simulation.from_free( ... EnvelopeGrid.from_limits(1e-3, 800e-9, (600e-9, 1.2e-6), 400e-15), ... RadialGrid(1e-3, 16), ... SpatialMedium(), diff --git a/src/solprop/recipe/export.py b/src/solprop/recipe/export.py index 1dfaea4a..ec7789de 100644 --- a/src/solprop/recipe/export.py +++ b/src/solprop/recipe/export.py @@ -15,6 +15,7 @@ from solprop.recipe.document import NONE from solprop.recipe.errors import RecipeError +from solprop.recipe.metadata import surface from solprop.recipe.registry import ( REGISTRY, BuildContext, @@ -174,6 +175,8 @@ def script( execution = dict(document.get("execution", {})) target = repr(output) title = recipe.title or f"a {recipe.route} run" + # The route's entry point: the radial route's is the free-space one. + entry = surface(recipe.route).__name__.removeprefix("from_") header = [f'"""{title}, written from its recipe by solprop."""', ""] if execution: writer.need("solprop.driver", "Simulation") @@ -182,7 +185,7 @@ def script( for key, item in execution.items() ) body = [ - f"simulation = Simulation.from_{recipe.route}(", + f"simulation = Simulation.from_{entry}(", *lines, ")", ] @@ -193,8 +196,8 @@ def script( ) body.append(f"output = simulation.run({target})") else: - writer.need("solprop.driver", f"prop_{recipe.route}") - body = [f"output = prop_{recipe.route}(", *lines] + writer.need("solprop.driver", f"prop_{entry}") + body = [f"output = prop_{entry}(", *lines] if output is not None: body.append(f"{_INDENT}output={target},") body.append(")") diff --git a/src/solprop/recipe/metadata.py b/src/solprop/recipe/metadata.py index 1e55c232..9421a0c9 100644 --- a/src/solprop/recipe/metadata.py +++ b/src/solprop/recipe/metadata.py @@ -113,7 +113,7 @@ def __repr__(self) -> str: SETTINGS_KEYWORDS: Final = ("absorber", "absorber_scheme") """Keywords of an object route's constructor that a recipe writes under ``settings``. -``prop_radial``, ``prop_free`` and ``prop_planar`` apply them to their settings; a +``prop_free`` and ``prop_planar`` apply them to their settings; a recipe holds the ``from_*`` form, so they are keywords of its ``settings`` table (SEP 41 section 5.1), and the route's table does not list them. """ @@ -1453,7 +1453,8 @@ def surface(route: str) -> "Callable[..., Simulation]": ------- callable The route's ``Simulation.from_*``: ``from_gnlse``, ``from_capillary``, - ``from_waveguide``, ``from_radial``, ``from_free`` or ``from_planar``. + ``from_waveguide``, ``from_free`` (the radial and the free routes) or + ``from_planar``. Raises ------ @@ -1472,7 +1473,7 @@ def surface(route: str) -> "Callable[..., Simulation]": "gnlse": Simulation.from_gnlse, "capillary": Simulation.from_capillary, "waveguide": Simulation.from_waveguide, - "radial": Simulation.from_radial, + "radial": Simulation.from_free, "free": Simulation.from_free, "planar": Simulation.from_planar, } diff --git a/src/solprop/sol.py b/src/solprop/sol.py index 1f65935a..06df68d3 100644 --- a/src/solprop/sol.py +++ b/src/solprop/sol.py @@ -47,7 +47,6 @@ prop_free, prop_gnlse, prop_planar, - prop_radial, prop_waveguide, ) from solprop.driver.scan import Scan, ScanPoint, run_scan @@ -466,7 +465,6 @@ "prop_free", "prop_gnlse", "prop_planar", - "prop_radial", "prop_waveguide", "pulse_energy", "pulse_peak_power", diff --git a/tests/backends/test_far_field_statistics.py b/tests/backends/test_far_field_statistics.py index a989beb4..4b349943 100644 --- a/tests/backends/test_far_field_statistics.py +++ b/tests/backends/test_far_field_statistics.py @@ -216,7 +216,7 @@ def test_the_bands_follow_the_shapes_and_the_budget(monkeypatch): def test_a_radial_problem_takes_no_partials(grid): - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(3e-4, 8), SpatialMedium(), @@ -670,7 +670,7 @@ def test_the_operator_refuses_a_share_above_the_total(grid, lossy_launch): ) def test_radial_and_planar_geometry_are_refused_before_any_step(grid): spec = StatsSpec(names=("energy", "beam_spectrum")) - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(3e-4, 16), SpatialMedium(), diff --git a/tests/backends/test_flux_gradients.py b/tests/backends/test_flux_gradients.py index a6de64f3..623538fa 100644 --- a/tests/backends/test_flux_gradients.py +++ b/tests/backends/test_flux_gradients.py @@ -70,7 +70,7 @@ def _run( if steps is None else FixedSchedule.uniform(0.0, LENGTH, LENGTH / steps), ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, list(responses), initial, settings=settings, noise=None ) output = simulation.run() diff --git a/tests/backends/test_flux_parity.py b/tests/backends/test_flux_parity.py index 8711b2f6..4bd360f3 100644 --- a/tests/backends/test_flux_parity.py +++ b/tests/backends/test_flux_parity.py @@ -101,7 +101,7 @@ def _settings(names, **extra): def _radial(medium): grid = _grid() space = RadialGrid(6e-4, 16) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, [], _pulse(60e-6), settings=_settings(LOCAL), noise=None ) return grid, simulation.translate(), simulation._run_request() diff --git a/tests/backends/test_flux_paths.py b/tests/backends/test_flux_paths.py index bd7b07ba..69829b07 100644 --- a/tests/backends/test_flux_paths.py +++ b/tests/backends/test_flux_paths.py @@ -61,7 +61,7 @@ def _case( adiabatic_accuracy=accuracy, absorber=bare_absorber(grid), ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 16), medium, [], pulse, settings=settings, noise=None ) diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py index 0af32471..627b1375 100644 --- a/tests/backends/test_native_edge_collar.py +++ b/tests/backends/test_native_edge_collar.py @@ -150,7 +150,7 @@ def _case( ) pulse = SpatialPulse(temporal, waist, propz=propz) if route == "radial": - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(300e-6, 48, collar_fraction=1.0), medium, diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 22a70744..a147c334 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -203,7 +203,7 @@ def _polarised(route: str) -> Simulation: medium = SpatialMedium("Ar", 1.0) settings = _case(route).settings if route == "radial": - return Simulation.from_radial( + return Simulation.from_free( SPATIAL_GRID, RadialGrid(3e-4, 16, collar_fraction=0), medium, @@ -640,7 +640,7 @@ def test_a_field_grid_reads_as_the_host_does() -> None: ) medium = SpatialMedium("Ar", 1.0) simulations = [ - Simulation.from_radial( + Simulation.from_free( grid, RadialGrid(6e-4, 32), medium, diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index 5682429f..92921f94 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -862,7 +862,7 @@ def _radial_route(kind="envelope"): pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(3e-4, 32), LOSSY, [], pulse, settings=settings, noise=None ) return grid, simulation.translate(), simulation._run_request() diff --git a/tests/backends/test_transverse_backends.py b/tests/backends/test_transverse_backends.py index d5090d3f..e27d8328 100644 --- a/tests/backends/test_transverse_backends.py +++ b/tests/backends/test_transverse_backends.py @@ -155,7 +155,7 @@ def radial_collar( backend=backend, spatial_absorber=SpatialAbsorber(kappa=8e4), ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(300e-6, 48, collar_fraction=1.0), SpatialMedium(1.0), diff --git a/tests/capability_routes.py b/tests/capability_routes.py index c13a4972..9082f295 100644 --- a/tests/capability_routes.py +++ b/tests/capability_routes.py @@ -261,7 +261,7 @@ def _spatial_pulse() -> SpatialPulse: def radial(noise: str | None) -> Simulation: """Return the radial free-space route, 32 Hankel nodes.""" medium, responses = _argon() - return Simulation.from_radial( + return Simulation.from_free( SPATIAL_GRID, RadialGrid(6e-4, 32), medium, diff --git a/tests/coupling/test_couple.py b/tests/coupling/test_couple.py index 09e3d653..6ac8d7e4 100644 --- a/tests/coupling/test_couple.py +++ b/tests/coupling/test_couple.py @@ -1099,7 +1099,7 @@ def test_the_report_prints_and_carries_its_terms(port): def test_a_source_path_certificate_is_carried_beside_the_coupling(port): """A gradient run's rebuilt certificate reaches the report, separately.""" grid = FieldGrid.from_limits(2e-3, 800e-9, (600e-9, 1.2e-6), 400e-15) - gradient = Simulation.from_radial( + gradient = Simulation.from_free( grid, RadialGrid(6e-4, 32), SpatialMedium("Ar", PressureGradient.two_point(2e-3, 1.0, 2.0)), diff --git a/tests/coupling/test_transport_plane.py b/tests/coupling/test_transport_plane.py index 4379faa2..05ff24ae 100644 --- a/tests/coupling/test_transport_plane.py +++ b/tests/coupling/test_transport_plane.py @@ -65,7 +65,7 @@ def radial_run(): GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 2.5e-5 ) medium = SpatialMedium("Ar", 1.0) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(3e-4, 64), medium, [], pulse, settings=settings, noise=None ) return simulation, simulation.run(progress="none") diff --git a/tests/coupling/test_workflows.py b/tests/coupling/test_workflows.py index c2b6b500..1818df47 100644 --- a/tests/coupling/test_workflows.py +++ b/tests/coupling/test_workflows.py @@ -268,7 +268,7 @@ def test_a_kerr_prepared_radial_beam_into_a_capillary(): ("linear", []), ("kerr", [KerrResponse(gamma3("Ar", wavelength=800e-9), thg=False)]), ): - gas = Simulation.from_radial( + gas = Simulation.from_free( grid, RadialGrid(4e-4, 96), ARGON, @@ -328,7 +328,7 @@ def test_a_field_grid_kerr_plasma_thg_entrance_is_transferred_with_its_harmonic( PlasmaResponse("Ar", OptmatRate("Ar", 800e-9)), ] medium = SpatialMedium("Ar", 1.0) - gas = Simulation.from_radial( + gas = Simulation.from_free( grid, RadialGrid(3e-4, 64), medium, diff --git a/tests/driver/test_absorber_keywords.py b/tests/driver/test_absorber_keywords.py index 64bc3a33..80aaab27 100644 --- a/tests/driver/test_absorber_keywords.py +++ b/tests/driver/test_absorber_keywords.py @@ -135,7 +135,7 @@ def test_spatial_absorber_false_keeps_the_lengths(grid: FieldGrid) -> None: def _radial(grid: EnvelopeGrid, **keywords: Any) -> Simulation: - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(radius=600e-6, n_r=16), SpatialMedium(), [], _beam(), noise=None, **keywords, ) # fmt: skip diff --git a/tests/driver/test_backend_default.py b/tests/driver/test_backend_default.py index d8ae1085..3d41ee77 100644 --- a/tests/driver/test_backend_default.py +++ b/tests/driver/test_backend_default.py @@ -23,7 +23,6 @@ prop_capillary, prop_free, prop_gnlse, - prop_radial, prop_waveguide, ) from solprop.exceptions import ( @@ -133,7 +132,7 @@ def run(simulation, *args, **kwargs): ) else: interface = ( - partial(prop_radial, narrow_grid, RadialGrid(6e-4, 8), noise=None) + partial(prop_free, narrow_grid, RadialGrid(6e-4, 8), noise=None) if kind == "radial" else partial( prop_free, narrow_grid, FreeGrid(3e-4, 8, 3e-4, 8), noise=None diff --git a/tests/driver/test_batch.py b/tests/driver/test_batch.py index 0acb19c3..132277df 100644 --- a/tests/driver/test_batch.py +++ b/tests/driver/test_batch.py @@ -151,7 +151,7 @@ def test_batch_refuses_implicit_adaptive_execution(): def test_batch_refuses_spatial_geometry_before_selecting_a_backend(): base = member(0, backend="numpy_ref").simulation - simulation = Simulation.from_radial( + simulation = Simulation.from_free( base.grid, RadialGrid(0.6e-3, 8), SpatialMedium(), diff --git a/tests/driver/test_coherent_capillary.py b/tests/driver/test_coherent_capillary.py index b4c1d6ad..2290f0a6 100644 --- a/tests/driver/test_coherent_capillary.py +++ b/tests/driver/test_coherent_capillary.py @@ -809,7 +809,7 @@ def test_a_wall_declared_on_another_route_is_refused_by_name(): constructors and the attribute `from_capillary` sets -- rather than through a hand-built assembly, because that is the path the leak was on. """ - spatial = Simulation.from_radial( + spatial = Simulation.from_free( capillary().grid, RadialGrid(0.6e-3, 8), SpatialMedium(), diff --git a/tests/driver/test_coupling_relaunch.py b/tests/driver/test_coupling_relaunch.py index 190d7001..9d0ed361 100644 --- a/tests/driver/test_coupling_relaunch.py +++ b/tests/driver/test_coupling_relaunch.py @@ -138,7 +138,7 @@ def radial(pressure=1.0, length=2e-3, backend="numpy_ref"): pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 32), SpatialMedium("Ar", pressure), diff --git a/tests/driver/test_harmonic_frame.py b/tests/driver/test_harmonic_frame.py index 4d2d9e11..10327a9f 100644 --- a/tests/driver/test_harmonic_frame.py +++ b/tests/driver/test_harmonic_frame.py @@ -191,7 +191,7 @@ def beam() -> GaussianPulse: def radial(grid, responses): """Return a radial simulation, choosing its frame in ``_from_spatial``.""" - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 32), SpatialMedium("Ar", 1.0), diff --git a/tests/driver/test_history_replay.py b/tests/driver/test_history_replay.py index e3caf491..d0db1ade 100644 --- a/tests/driver/test_history_replay.py +++ b/tests/driver/test_history_replay.py @@ -81,7 +81,7 @@ def radial(integrator: str, **changes): "backend": "numpy_ref", **changes, } - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium("Ar", 1.0), diff --git a/tests/driver/test_initial_z_and_checkpoint.py b/tests/driver/test_initial_z_and_checkpoint.py index 1574e656..1693addf 100644 --- a/tests/driver/test_initial_z_and_checkpoint.py +++ b/tests/driver/test_initial_z_and_checkpoint.py @@ -1214,7 +1214,7 @@ def test_interior_atomic_budget_message_matches_terminal_checkpoint( def test_spatial_resume_retains_physical_field_without_modal_normalisation(monkeypatch): grid = FieldGrid.from_limits(1e-5, 800e-9, (600e-9, 1.2e-6), 60e-15) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(3e-4, 8), SpatialMedium(), diff --git a/tests/driver/test_interface_defaults.py b/tests/driver/test_interface_defaults.py index 70189787..7bd470bf 100644 --- a/tests/driver/test_interface_defaults.py +++ b/tests/driver/test_interface_defaults.py @@ -126,10 +126,6 @@ def _spatial(space, controller=None, integrator=None, noise=None, copied=False): [], SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 60e-6), ) - if isinstance(space, RadialGrid): - return Simulation.from_radial( - grid, space, *arguments, settings=settings, noise=noise - ) return Simulation.from_free(grid, space, *arguments, settings=settings, noise=noise) @@ -261,7 +257,7 @@ def _build(route, through): # noqa: PLR0911 - one return per route family "gnlse": Simulation.from_gnlse, "capillary": Simulation.from_capillary, "waveguide": Simulation.from_waveguide, - "radial": Simulation.from_radial, + "radial": Simulation.from_free, "free": Simulation.from_free, "planar": Simulation.from_planar, }, @@ -269,7 +265,7 @@ def _build(route, through): # noqa: PLR0911 - one return per route family "gnlse": interface.prop_gnlse, "capillary": interface.prop_capillary, "waveguide": interface.prop_waveguide, - "radial": interface.prop_radial, + "radial": interface.prop_free, "free": interface.prop_free, "planar": interface.prop_planar, }, @@ -424,13 +420,11 @@ def test_a_slab_mode_set_carries_no_noise_and_refuses_a_model(): Simulation.from_gnlse, Simulation.from_capillary, Simulation.from_waveguide, - Simulation.from_radial, Simulation.from_free, Simulation.from_planar, interface.prop_gnlse, interface.prop_capillary, interface.prop_waveguide, - interface.prop_radial, interface.prop_free, interface.prop_planar, ] @@ -460,12 +454,12 @@ def _with_check(route, through, check): # The spatial entry points take it beside their settings. entry = { "from": { - "radial": Simulation.from_radial, + "radial": Simulation.from_free, "free": Simulation.from_free, "planar": Simulation.from_planar, }, "prop": { - "radial": interface.prop_radial, + "radial": interface.prop_free, "free": interface.prop_free, "planar": interface.prop_planar, }, @@ -512,7 +506,7 @@ def test_a_spatial_keyword_and_settings_naming_two_checks_are_refused(): relaxed = BoundaryCheck(budget=1.0, stop_factor=2.0) settings = dataclasses.replace(_settings(SPATIAL), boundary_check=None) with pytest.raises(DriverError, match="already declare a boundary_check"): - Simulation.from_radial( + Simulation.from_free( SPATIAL, RadialGrid(6e-4, 16), SpatialMedium(), @@ -522,7 +516,7 @@ def test_a_spatial_keyword_and_settings_naming_two_checks_are_refused(): boundary_check=relaxed, ) # The same check named twice is no conflict, and settings' own stands alone. - same = Simulation.from_radial( + same = Simulation.from_free( SPATIAL, RadialGrid(6e-4, 16), SpatialMedium(), diff --git a/tests/driver/test_material_manifest.py b/tests/driver/test_material_manifest.py index a8c1c0d8..2df1ad4a 100644 --- a/tests/driver/test_material_manifest.py +++ b/tests/driver/test_material_manifest.py @@ -327,7 +327,7 @@ def test_a_supplied_susceptibility_is_kept_and_a_glass_taken_as_it_is(free_case) space = RadialGrid(3e-4, 8) # optmat's Borzsonyi form, not the recommended fit: a caller's choice. other = gamma(Gas.Ar, model="borzsonyi_2008/sellmeier_Ar") - kept: Any = Simulation.from_radial( + kept: Any = Simulation.from_free( grid, space, SpatialMedium("Ar", Linear(1.0, 2.0, grid.length), susceptibility=other), @@ -341,7 +341,7 @@ def test_a_supplied_susceptibility_is_kept_and_a_glass_taken_as_it_is(free_case) assert component.species.susceptibility == other assert type(component.species.equation_of_state).__name__ == "OptmatGas" window = fused_silica(model="recommended") - glass: Any = Simulation.from_radial( + glass: Any = Simulation.from_free( grid, space, SpatialMedium(window), diff --git a/tests/driver/test_mixture_update.py b/tests/driver/test_mixture_update.py index e4563eb3..8a4788f5 100644 --- a/tests/driver/test_mixture_update.py +++ b/tests/driver/test_mixture_update.py @@ -183,7 +183,7 @@ def test_radial_update_shares_covered_operator_terms_and_input(source, constant) pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=10e-15, energy=1e-9), 80e-6 ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( source.grid, space, SpatialMedium(material), @@ -222,7 +222,7 @@ def test_radial_update_shares_covered_operator_terms_and_input(source, constant) def test_uncovered_constant_update_falls_back_to_constant_operator(source, geometry): material = source.modes[0].mixture if geometry == "radial": - source = Simulation.from_radial( + source = Simulation.from_free( source.grid, RadialGrid(6e-4, 8, collar_fraction=0.0), SpatialMedium(material), diff --git a/tests/driver/test_native_z_table.py b/tests/driver/test_native_z_table.py index 454b0b1b..037e90b2 100644 --- a/tests/driver/test_native_z_table.py +++ b/tests/driver/test_native_z_table.py @@ -150,7 +150,7 @@ def test_a_radial_run_through_three_independent_species(integrator): for index, label in enumerate(fill.labels) } ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(1e-3, 16, collar_fraction=0.0), SpatialMedium(fill), diff --git a/tests/driver/test_noise_default.py b/tests/driver/test_noise_default.py index 0c6b4ada..639bdc83 100644 --- a/tests/driver/test_noise_default.py +++ b/tests/driver/test_noise_default.py @@ -108,7 +108,7 @@ def test_the_capillary_and_radial_routes_default_to_input_noise(): dz_max=grid.length, dz_init=grid.length, ) - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(6e-4, 16), SpatialMedium(), diff --git a/tests/driver/test_prop_radial.py b/tests/driver/test_prop_radial.py index 6144ddbe..538bcb57 100644 --- a/tests/driver/test_prop_radial.py +++ b/tests/driver/test_prop_radial.py @@ -15,7 +15,7 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from solprop.backends.registry import available from solprop.constants import EPS0, HBAR, C -from solprop.driver import Simulation, SimulationSettings, prop_radial +from solprop.driver import Simulation, SimulationSettings, prop_free from solprop.exceptions import DriverError, NoiseError, OutputError from solprop.fourier import spectral_density from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid @@ -65,7 +65,7 @@ def case(request): def test_radial_interface_caches_and_saves_geometry(case, backend, request): grid, space, pulse, settings = case settings = dataclasses.replace(settings, backend=backend) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) assert simulation.translate() is simulation.translate() @@ -82,7 +82,7 @@ def test_radial_interface_caches_and_saves_geometry(case, backend, request): RunMetadata.from_json(metadata.to_json()).configuration_hash() == metadata.configuration_hash() ) - handler = prop_radial( + handler = prop_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) fields = handler.fields("fft") @@ -162,7 +162,7 @@ def test_spatial_noise_has_one_photon_in_every_column_bin(case, model, n_pol, re def test_radial_simulation_records_noise_model_and_seed(case, model, backend, request): grid, space, pulse, settings = case settings = dataclasses.replace(settings, backend=backend) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=model, seed=33 ) assert simulation.noise is not None @@ -197,14 +197,14 @@ def test_radial_simulation_records_noise_model_and_seed(case, model, backend, re def test_spatial_resume_refuses_geometry_changes(case, tmp_path): grid, space, pulse, settings = case settings = dataclasses.replace(settings, backend="numpy_ref") - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) path = tmp_path / "radial.h5" simulation.run(path) assert check_cache(path, simulation.metadata()) simulation.run(path, resume=True) - changed = Simulation.from_radial( + changed = Simulation.from_free( grid, dataclasses.replace(space, radius=space.radius * 2), SpatialMedium(), @@ -220,7 +220,7 @@ def test_spatial_resume_refuses_geometry_changes(case, tmp_path): def test_radial_input_rejects_ambiguous_noise_and_shape(case): grid, space, pulse, settings = case with pytest.raises(DriverError, match="seed"): - Simulation.from_radial( + Simulation.from_free( grid, space, SpatialMedium(), @@ -231,7 +231,7 @@ def test_radial_input_rejects_ambiguous_noise_and_shape(case): noise=None, ) with pytest.raises(DriverError, match="shape"): - Simulation.from_radial( + Simulation.from_free( grid, space, SpatialMedium(), diff --git a/tests/driver/test_route_keywords.py b/tests/driver/test_route_keywords.py index 4fb6b7e1..d86171a9 100644 --- a/tests/driver/test_route_keywords.py +++ b/tests/driver/test_route_keywords.py @@ -11,17 +11,22 @@ changes behaviour. """ +import annotationlib import inspect import numpy as np import pytest from numpy.testing import assert_array_equal +from numpydoc.docscrape import FunctionDoc +from numpydoc.validate import validate from modal_provider_cases import GENERAL_Z_END, general_grid, general_guide from solprop.driver import ( Simulation, prop_capillary, + prop_free, prop_gnlse, + prop_planar, prop_waveguide, ) from solprop.modes import MarcatiliMode @@ -108,6 +113,58 @@ def test_each_interface_takes_the_keywords_with_its_constructor_s_defaults( assert build[name].default == defaults.get(name, False), name +RUN_CONTROLS = ("output", "snapshots", "resume", "progress", "progress_period") +INTERFACES = [ + (prop_free, Simulation.from_free, RUN_CONTROLS), + (prop_planar, Simulation.from_planar, RUN_CONTROLS), + (prop_gnlse, Simulation.from_gnlse, RUN_CONTROLS), + (prop_capillary, Simulation.from_capillary, (*RUN_CONTROLS, "tdse_archive")), + (prop_waveguide, Simulation.from_waveguide, RUN_CONTROLS), +] +# The checks `poe docstrings` applies (pyproject.toml, [tool.numpydoc_validation]). +UNCHECKED = {"ES01", "SA01", "EX01", "GL01", "GL02"} + + +def _signature(function): + # Annotations as written, so that two spellings of one jaxtyping type compare. + return inspect.signature(function, annotation_format=annotationlib.Format.STRING) + + +@pytest.mark.parametrize(("interface", "constructor", "controls"), INTERFACES) +def test_each_interface_is_its_constructor_and_the_run_controls( + interface, constructor, controls +): + build = _signature(constructor) + run = _signature(Simulation.run).parameters + expected = build.replace( + parameters=[ + *build.parameters.values(), + *( + run[name].replace(kind=inspect.Parameter.KEYWORD_ONLY) + for name in controls + ), + ], + return_annotation="Output", + ) + assert _signature(interface) == expected + # Signature equality ignores the order of keyword-only parameters; the + # documentation's order follows the signature's, so it is checked too. + assert list(_signature(interface).parameters) == list(expected.parameters) + + +@pytest.mark.parametrize(("interface", "constructor", "controls"), INTERFACES) +def test_each_interface_s_composed_docstring_is_valid(interface, constructor, controls): + name = f"{interface.__module__}.{interface.__name__}" + errors = [code for code, _ in validate(name)["errors"] if code not in UNCHECKED] + assert errors == [] + documented = [ + name + for entry in FunctionDoc(interface)["Parameters"] + for name in entry.name.split(", ") + ] + assert documented == [*inspect.signature(constructor).parameters, *controls] + + # ------------------------------------------------------------------ the planes diff --git a/tests/driver/test_scan_derived_noise.py b/tests/driver/test_scan_derived_noise.py index 84df3b02..1221d58c 100644 --- a/tests/driver/test_scan_derived_noise.py +++ b/tests/driver/test_scan_derived_noise.py @@ -135,7 +135,7 @@ def shared(point): def radial(medium=None, **overrides): """Return a small radial run in helium, by default at one bar.""" grid = EnvelopeGrid.from_limits(1e-3, 800e-9, (600e-9, 1.2e-6), 150e-15, thg=False) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 8), SpatialMedium(Mixture.fill({"He": 1.0}, 1.0)) if medium is None else medium, diff --git a/tests/driver/test_summary.py b/tests/driver/test_summary.py index d3157dda..94001691 100644 --- a/tests/driver/test_summary.py +++ b/tests/driver/test_summary.py @@ -232,7 +232,7 @@ def spatial_case(grid_kind="field"): def test_radial_cartesian_and_line_routes(): grid, pulse, settings = spatial_case() - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(6e-4, 32), SpatialMedium(), @@ -484,7 +484,7 @@ def test_representation_of_a_general_guide_and_of_two_polarisations(): assert _representation(problem, general) == "general guide, 1 channels, scalar" grid, pulse, settings = spatial_case() circular = dataclasses.replace(pulse, jones=(1.0 + 0.0j, 1.0j)) - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(6e-4, 16), SpatialMedium(), diff --git a/tests/processing/test_mixtures.py b/tests/processing/test_mixtures.py index 7862106c..e8c1ef2b 100644 --- a/tests/processing/test_mixtures.py +++ b/tests/processing/test_mixtures.py @@ -83,7 +83,7 @@ def case(request): else FreeGrid(3e-4, 4, 4e-4, 4, collar_fraction=0.0) ) if isinstance(space, RadialGrid): - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(material), @@ -309,7 +309,7 @@ def test_a_spatial_record_without_its_plane_measure_is_refused(case): def test_coarse_radial_fluence_matches_direct_bessel_synthesis(): material, grid, _, _, (_, run_request) = mixture_translation() space = RadialGrid(6e-4, 8, collar_fraction=0.0) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(material), diff --git a/tests/processing/test_planes.py b/tests/processing/test_planes.py index 8d024032..d95d8678 100644 --- a/tests/processing/test_planes.py +++ b/tests/processing/test_planes.py @@ -88,7 +88,7 @@ def spatial( ) medium = SpatialMedium("Ar", pressure) if isinstance(space, RadialGrid): - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, [], pulse, settings=settings, noise=None ) return Simulation.from_free( diff --git a/tests/processing/test_spatial_readers.py b/tests/processing/test_spatial_readers.py index 7f62b5e1..c39ca7b4 100644 --- a/tests/processing/test_spatial_readers.py +++ b/tests/processing/test_spatial_readers.py @@ -136,8 +136,7 @@ def _simulation(route: str) -> Simulation: pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=ENERGY), 6e-5 ) - build = Simulation.from_radial if route == "radial" else Simulation.from_free - return build( + return Simulation.from_free( grid, space, SpatialMedium("Ar", 1.0), diff --git a/tests/recipe/test_examples.py b/tests/recipe/test_examples.py index a5f7732b..ae69d38c 100644 --- a/tests/recipe/test_examples.py +++ b/tests/recipe/test_examples.py @@ -55,13 +55,11 @@ "prop_gnlse", "prop_capillary", "prop_waveguide", - "prop_radial", "prop_free", "prop_planar", "from_gnlse", "from_capillary", "from_waveguide", - "from_radial", "from_free", "from_planar", } diff --git a/tests/recipe/test_free_space.py b/tests/recipe/test_free_space.py index 08fc2310..e9d499d1 100644 --- a/tests/recipe/test_free_space.py +++ b/tests/recipe/test_free_space.py @@ -105,7 +105,7 @@ def _radial_call() -> Simulation: ionisation_field_range=(1e8, 1e11), backend="numpy_ref", ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(400e-6, 32), SpatialMedium("Ar", pressure=1.0), @@ -152,7 +152,7 @@ def _radial_call() -> Simulation: def _field_call() -> Simulation: grid = FieldGrid.from_limits(0.2e-3, 800e-9, (400e-9, 2e-6), 150e-15) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(200e-6, 16, collar_fraction=0.25), SpatialMedium("Ar", pressure=2.0, temperature=300.0), diff --git a/tests/recipe/test_metadata.py b/tests/recipe/test_metadata.py index a07bdfbd..cc3cdd57 100644 --- a/tests/recipe/test_metadata.py +++ b/tests/recipe/test_metadata.py @@ -20,7 +20,6 @@ prop_free, prop_gnlse, prop_planar, - prop_radial, prop_waveguide, ) from solprop.driver.settings import SimulationSettings @@ -36,7 +35,7 @@ "gnlse": Simulation.from_gnlse, "capillary": Simulation.from_capillary, "waveguide": Simulation.from_waveguide, - "radial": Simulation.from_radial, + "radial": Simulation.from_free, "free": Simulation.from_free, "planar": Simulation.from_planar, } @@ -44,7 +43,7 @@ "gnlse": prop_gnlse, "capillary": prop_capillary, "waveguide": prop_waveguide, - "radial": prop_radial, + "radial": prop_free, "free": prop_free, "planar": prop_planar, } diff --git a/tests/record_routes.py b/tests/record_routes.py index c487bc69..3b28e16c 100644 --- a/tests/record_routes.py +++ b/tests/record_routes.py @@ -91,7 +91,7 @@ def _pulse() -> SpatialPulse: def radial() -> Simulation: """Return a small radial run.""" grid = _spatial_grid() - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(250e-6, 16, collar_fraction=0.2), SpatialMedium(), diff --git a/tests/sessions/test_native_session_statistics.py b/tests/sessions/test_native_session_statistics.py index 3c3e80c2..eaa67345 100644 --- a/tests/sessions/test_native_session_statistics.py +++ b/tests/sessions/test_native_session_statistics.py @@ -824,8 +824,7 @@ def _beam(route): pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 ) - build = Simulation.from_radial if route == "radial" else Simulation.from_free - simulation = build( + simulation = Simulation.from_free( grid, space, SpatialMedium(1.45 + 2e-3j), diff --git a/tests/test_display.py b/tests/test_display.py index 4f6a3084..406d2369 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -1003,7 +1003,7 @@ def test_a_spatial_simulation_names_its_space(): dz_max=grid.length, dz_init=grid.length, ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) assert "RadialGrid" in repr(simulation) diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 9d7329e0..5c481400 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -229,7 +229,7 @@ def _split( def _radial_walker(backend: str, scheme: AbsorberScheme, dz_max: float) -> Simulation: """Return a 1 pJ radial pulse at 1150 nm whose group delay walks out of 400 fs.""" grid = FieldGrid.from_limits(1.0, 800e-9, (500e-9, 1.3e-6), 400e-15) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1.2e-2, 16), SpatialMedium("Ar", 60.0), diff --git a/tests/test_spatial_boundaries.py b/tests/test_spatial_boundaries.py index a8c8c622..6e88de65 100644 --- a/tests/test_spatial_boundaries.py +++ b/tests/test_spatial_boundaries.py @@ -145,7 +145,7 @@ def test_spatial_metadata_requires_geometry_and_the_current_schema( case, changes, refusal ): grid, space, pulse, settings = case - metadata = Simulation.from_radial( + metadata = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ).metadata() with pytest.raises(OutputError, match=refusal): @@ -177,7 +177,7 @@ def test_spatial_response_compilation_requires_a_translated_prefactor(case): @pytest.mark.parametrize("change", ["polarisation", "matrix"]) def test_radial_transform_rejects_incompatible_scalar_map(case, change): grid, space, pulse, settings = case - problem = Simulation.from_radial( + problem = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ).translate() transform = problem.transform @@ -196,7 +196,7 @@ def test_radial_transform_rejects_incompatible_scalar_map(case, change): def test_spatial_response_compilation_rejects_modal_averaging(case): grid, space, pulse, settings = case - problem = Simulation.from_radial( + problem = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ).translate() with pytest.raises(TranslationError, match="mode_averaged=False"): @@ -214,7 +214,7 @@ def test_radial_assembly_rejects_wrong_column_count(case): grid, space, pulse, settings = case initial = pulse.build(grid, space, medium=SpatialMedium())[:-1] with pytest.raises(DriverError, match="spatial input"): - Simulation.from_radial( + Simulation.from_free( grid, space, SpatialMedium(), [], initial, settings=settings, noise=None ) with pytest.raises(TranslationError, match="spatial columns"): diff --git a/tests/test_split_crossing.py b/tests/test_split_crossing.py index d2cd3a4b..ca9a7bf9 100644 --- a/tests/test_split_crossing.py +++ b/tests/test_split_crossing.py @@ -95,7 +95,7 @@ def _gnlse( def _radial(backend: str) -> Simulation: """Return a 1 pJ pulse at 1150 nm in 60 bar of argon on a 400 fs window.""" grid = FieldGrid.from_limits(1.0, 800e-9, (500e-9, 1.3e-6), 400e-15) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1.2e-2, 16), SpatialMedium("Ar", 60.0), @@ -221,7 +221,7 @@ def _beam(backend: str, nodes: int, trange: float, fraction: float) -> Simulatio index = float(np.real(medium.index(np.array([2 * math.pi * C / 800e-9]))[0])) power = fraction * critical_power(800e-9, chi3 * density, index=index) energy = power * 50e-15 * math.sqrt(math.pi / (4 * math.log(2))) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1e-3, nodes), medium, diff --git a/tests/test_transverse_nonlinear.py b/tests/test_transverse_nonlinear.py index e5a33a71..5f471658 100644 --- a/tests/test_transverse_nonlinear.py +++ b/tests/test_transverse_nonlinear.py @@ -49,7 +49,7 @@ def kerr_suite( index = float(np.real(medium.index(np.array([2 * math.pi * C / 800e-9]))[0])) power = 0.9 * critical_power(800e-9, chi3 * density, index=index) energy = power * 30e-15 * math.sqrt(math.pi / (4 * math.log(2))) - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, diff --git a/tests/test_transverse_resolution.py b/tests/test_transverse_resolution.py index 30c79109..477d9f6c 100644 --- a/tests/test_transverse_resolution.py +++ b/tests/test_transverse_resolution.py @@ -48,7 +48,7 @@ def suite( if energy > 0 else np.zeros((space.n_r, 1, grid.n_omega), dtype=complex) ) - return Simulation.from_radial( + return Simulation.from_free( grid, space, SpatialMedium("Ar", 1.0), @@ -209,7 +209,7 @@ def test_the_keyword_and_the_settings_cannot_both_name_a_boundary() -> None: simulation.settings, spatial_absorber=SpatialAbsorber(kappa=2e5) ) with pytest.raises(DriverError, match="not both"): - Simulation.from_radial( + Simulation.from_free( simulation.grid, simulation.spatial_grid, # pyright: ignore[reportArgumentType] simulation.medium, # pyright: ignore[reportArgumentType] diff --git a/tests/translate/test_coupling.py b/tests/translate/test_coupling.py index 468af8df..64218a06 100644 --- a/tests/translate/test_coupling.py +++ b/tests/translate/test_coupling.py @@ -30,7 +30,7 @@ def radial(): pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 16), SpatialMedium("Ar", 1.0), diff --git a/tests/translate/test_operator_settings.py b/tests/translate/test_operator_settings.py index 667d5cb6..6797e823 100644 --- a/tests/translate/test_operator_settings.py +++ b/tests/translate/test_operator_settings.py @@ -389,7 +389,7 @@ def radial( space = RadialGrid(4e-6, 16) pulse = evanescent_beam(grid, space, SpatialMedium()) planes = [length] - return Simulation.from_radial( + return Simulation.from_free( grid, space, SpatialMedium(), diff --git a/tests/translate/test_power_boundaries.py b/tests/translate/test_power_boundaries.py index 98890b92..95d1fb57 100644 --- a/tests/translate/test_power_boundaries.py +++ b/tests/translate/test_power_boundaries.py @@ -155,10 +155,10 @@ def test_spatial_contract_refuses_a_modal_weight(grid): dz_max=grid.length, dz_init=grid.length, ) - problem = Simulation.from_radial( + problem = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ).translate() - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) simulation.translate() diff --git a/tests/translate/test_problem.py b/tests/translate/test_problem.py index acd4bddf..f19f6f04 100644 --- a/tests/translate/test_problem.py +++ b/tests/translate/test_problem.py @@ -1596,7 +1596,7 @@ def radial_problem(): pulse = SpatialPulse( GaussianPulse(wavelength=WAVELENGTH, fwhm=30e-15, energy=1e-9), 60e-6 ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(6e-4, 16), SpatialMedium("Ar", pressure=1.0), diff --git a/tests/translate/test_routes.py b/tests/translate/test_routes.py index 936510e5..9fa75a4f 100644 --- a/tests/translate/test_routes.py +++ b/tests/translate/test_routes.py @@ -79,7 +79,7 @@ def spatial(kind): ) pulse = SpatialPulse(temporal, 40e-6) if kind == "radial": - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(300e-6, 16), SpatialMedium(1.0), From 38c87c495d0050e8b7df0629bf703db6b4d99053 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:21:52 +0000 Subject: [PATCH 29/42] Revert "Route pressure_zdw and pressure_rdw through solve_parameter" This reverts commit e39cea3eb4847366b2f8d92de8f0821dc48a4684. --- .../pressure-solvers-search.changed.md | 7 - docs/guide/workflows/design-tools.md | 15 +- src/solprop/tools/design.py | 170 +++++++++++------- tests/test_design_solvers.py | 36 ++-- tests/test_soliton_content.py | 43 ++--- tests/test_tools.py | 9 +- 6 files changed, 142 insertions(+), 138 deletions(-) delete mode 100644 changelog.d/pressure-solvers-search.changed.md diff --git a/changelog.d/pressure-solvers-search.changed.md b/changelog.d/pressure-solvers-search.changed.md deleted file mode 100644 index 6be432aa..00000000 --- a/changelog.d/pressure-solvers-search.changed.md +++ /dev/null @@ -1,7 +0,0 @@ -`solprop.tools.pressure_zdw` and `solprop.tools.pressure_rdw` now run the search of -`solprop.tools.solve_parameter`. `pressure_zdw` samples the pressure range rather than -taking one Brent bracket over it: where the dispersion has one zero in the range it finds -the same root, to within the noise of the differenced dispersion (a few parts in 1e8); a -sign change through a resonance is no longer taken for a zero; and with several zeros it -returns the lowest. When no pressure in the range meets the target, both now say so as -`solve_parameter` does, naming the range of the quantity the range gives. diff --git a/docs/guide/workflows/design-tools.md b/docs/guide/workflows/design-tools.md index 1e3bb4fe..49bb5778 100644 --- a/docs/guide/workflows/design-tools.md +++ b/docs/guide/workflows/design-tools.md @@ -292,15 +292,12 @@ rests on an extrapolated cladding index. ## The pressure that tunes it {py:func}`~solprop.tools.design.pressure_rdw` inverts the calculation: the pressure at which -the dispersive wave is phase matched at a target wavelength, searched between -$10^{-6}$ bar and `pressure_max` as {py:func}`~solprop.tools.design.solve_parameter` -searches (below), and refused when there is more than one. With `peak_power` the -nonlinear coefficient follows the pressure inside the search. -{py:func}`~solprop.tools.design.pressure_zdw` does the same for the zero-dispersion -wavelength, and takes the lowest root. The capillary is built once and re-evaluated at -each density through its parameterised form, rather than rebuilt with its equation of -state at every step, so a trial pressure costs about a millisecond and a search a few -tenths of a second. +the dispersive wave is phase matched at a target wavelength, by Brent's method between +$10^{-6}$ bar and `pressure_max`. With `peak_power` the nonlinear coefficient follows the +pressure inside the search. {py:func}`~solprop.tools.design.pressure_zdw` does the same for the +zero-dispersion wavelength. The capillary is built once and re-evaluated at each density +through its parameterised form, rather than rebuilt with its equation of state at every +step, so a search costs milliseconds. ```{code-block} python >>> from solprop.tools import lambda_rdw, pressure_rdw diff --git a/src/solprop/tools/design.py b/src/solprop/tools/design.py index bd361ec4..6ff8de4b 100644 --- a/src/solprop/tools/design.py +++ b/src/solprop/tools/design.py @@ -78,7 +78,7 @@ _SEARCH_SAMPLES = 129 """Linear samples of a parameter search, and logarithmic ones beside them where the -range is positive: the grid of :func:`solve_parameter` and the pressure solvers.""" +range is positive: :func:`pressure_rdw`'s grid, which :func:`solve_parameter` shares.""" _ATTAINABLE_SAMPLES = 17 @@ -104,12 +104,35 @@ :data:`~solprop.tools.waveguide._POLE_FACTOR`), so the residual's own size decides.""" -def _pressure_bracket(pressure_max: float) -> tuple[float, float]: - """Return the pressure bracket in bar from the floor to `pressure_max`.""" +def _pressure_root( + residual: Callable[[float], float], pressure_max: float, what: str +) -> float: + """Return the pressure at which a function of pressure vanishes. + + - ``residual``: a function of the pressure in bar. + - ``pressure_max``: upper end of the bracket in bar. + - Returns: the root in bar. + """ top = positive(pressure_max, "pressure_max", ToolsError) if top <= _PRESSURE_FLOOR: raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") - return _PRESSURE_FLOOR, top + with _probing(): + low, high = residual(_PRESSURE_FLOOR), residual(top) + if not (math.isfinite(low) and math.isfinite(high)): + raise ToolsError( + f"the residual for {what} is not finite at the ends of the pressure " + f"bracket ({low!r}, {high!r}): the index model does not reach that far" + ) + if low * high > 0.0: + raise ToolsError( + f"no pressure between {_PRESSURE_FLOOR} and {top:.4g} bar gives {what}: " + f"the residual is {low:.4g} at the low end and {high:.4g} at the high end" + ) + with _probing(): + root = float(brentq(residual, _PRESSURE_FLOOR, top)) + # Outside the probing context, so the answer's own warnings are raised. + residual(root) + return root def _design_capillary( @@ -292,6 +315,39 @@ def refine(value: float) -> float: ) +def _rdw_pressure_root( + residual: Callable[[float], float | None], limits: tuple[float, float] +) -> float: + """Find a unique pressure root without crossing a sampled inadmissible region. + + - ``residual``: mismatch in 1/m, or None only where no focusing discrete soliton + exists. + - ``limits``: positive ordered pressure bracket in bar. + - Returns: unique pressure root in bar. + """ + low, high = limits + if not (math.isfinite(low) and math.isfinite(high) and 0 < low < high): + raise ToolsError("pressure_limits must be finite, positive and ordered") + roots = _sampled_roots( + residual, limits, what="the RDW pressure residual", narrow="pressure_limits" + ).roots + if not roots: + raise ToolsError( + f"no pressure between {low:g} and {high:g} bar gives an admissible " + "RDW root; narrow pressure_limits if needed" + ) + root, *others = roots + if others: + found = ", ".join(f"{value:.4g}" for value in roots) + raise ToolsError( + f"ambiguous RDW pressure: {len(roots)} roots, at {found} bar; " + "choose one with pressure_limits" + ) + # Re-evaluate the accepted answer outside the probing warning filter. + residual(root) + return root + + def pressure_rdw( radius: float, gas: Gas | str, @@ -383,11 +439,11 @@ def pressure_rdw( shape=shape, nonlinear_phase=nonlinear_phase, ) - floor, top = _pressure_bracket(pressure_max) - low, high = (floor, top) if pressure_limits is None else pressure_limits - if not (math.isfinite(low) and math.isfinite(high) and 0 < low < high): - raise ToolsError("pressure_limits must be finite, positive and ordered") - if low < floor or high > top: + top = positive(pressure_max, "pressure_max", ToolsError) + if top <= _PRESSURE_FLOOR: + raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") + limits = (_PRESSURE_FLOOR, top) if pressure_limits is None else pressure_limits + if limits[0] < _PRESSURE_FLOOR or limits[1] > top: raise ToolsError("pressure_limits must lie within (1e-6, pressure_max) bar") materials = MaterialSelection() if materials is None else materials _check_carrier(materials, wavelength) @@ -402,23 +458,21 @@ def pressure_rdw( materials=materials, ) - def _build(pressure: float) -> Mode | tuple[Mode, float]: - """Return the capillary at a pressure in bar, with its gamma for a Kerr term.""" + def _residual(pressure: float) -> float | None: + """Return the mismatch at the target for one pressure. + + - ``pressure``: pressure in bar. + - Returns: mismatch in 1/m, or None when the constituent does not exist. + """ mode = _capillary_at(base_mode, pressure) - if peak_power is None: - return mode - assert mode.mixture is not None - index = _fill_n2(mode.mixture, wavelength)[0] - return mode, nonlinear_coefficient(mode, index, wavelength) - - roots = _solve(_build, (low, high), design, narrow="pressure_limits") - if len(roots) > 1: - found = ", ".join(f"{value:.4g}" for value in roots) - raise ToolsError( - f"ambiguous RDW pressure: {len(roots)} roots, at {found} bar; " - "choose one with pressure_limits" - ) - return roots.pick() + gamma = None + if peak_power is not None: + assert mode.mixture is not None + index = _fill_n2(mode.mixture, wavelength)[0] + gamma = nonlinear_coefficient(mode, index, wavelength) + return _rdw_residual(design, mode, gamma, 0.0) + + return _rdw_pressure_root(_residual, limits) def pressure_zdw( @@ -435,9 +489,6 @@ def pressure_zdw( ) -> float: """Return the pressure that puts the zero-dispersion point at a wavelength. - The capillary form of :func:`solve_parameter` over pressure, with a - :class:`ZDW` target: the lowest root, should there be several. - Parameters ---------- radius : float @@ -470,8 +521,7 @@ def pressure_zdw( Raises ------ ToolsError - If no pressure in the bracket does, naming the zero-dispersion wavelengths - that the bracket gives. + If no pressure in the bracket does. Examples -------- @@ -479,8 +529,6 @@ def pressure_zdw( >>> f"{pressure_zdw(125e-6, 'Ar', 500e-9, cladding=1.45):.3f}" '0.066' """ - bracket = _pressure_bracket(pressure_max) - target = ZDW(zdw_wavelength) materials = MaterialSelection() if materials is None else materials base_mode = _design_capillary( radius, @@ -492,13 +540,24 @@ def pressure_zdw( lattice=None, materials=materials, ) - roots = _solve( - lambda pressure: _capillary_at(base_mode, pressure), - bracket, - target, - narrow="pressure_max", + omega = np.array([_omega(zdw_wavelength)]) + + def _residual(pressure: float) -> float: + """Return the dispersion at the wavelength for one pressure. + + - ``pressure``: pressure in bar. + - Returns: ``beta_2`` in s^2/m, scaled to order one. + """ + mode = _capillary_at(base_mode, pressure) + # Scaled so that brentq's absolute tolerance on the residual is meaningless + # and its tolerance on the pressure decides, as intended. + return float(base.beta2(mode, omega)[0]) * 1e30 + + return _pressure_root( + _residual, + pressure_max, + f"a zero-dispersion wavelength of {zdw_wavelength * 1e9:.4g} nm", ) - return roots.pick() @dataclass(frozen=True) @@ -1054,14 +1113,15 @@ def solve_parameter( """Return every value of a guide or fill parameter that meets a design target. The general form of :func:`pressure_zdw`, :func:`pressure_rdw` and - :func:`radius_zdw`, which call it, for any mode and any parameter: a radius, a - pressure, a core size or a wall thickness. The target's residual is sampled over - `search` on 129 linear values and, where ``low > 0``, 129 logarithmic ones; each - sign change between two samples is refined with Brent's method to a relative - tolerance of 2e-12; and a refined change is kept as a root only where the residual - has fallen to a thousandth of its value at the bracket's ends, which discards a - change through a pole (a wall resonance, where it diverges instead). Roots closer - together than one sample are not separated: narrow `search` to resolve them. + :func:`radius_zdw`, for any mode and any parameter: a radius, a pressure, a core + size or a wall thickness. The target's residual is sampled over `search` on 129 + linear values and, where ``low > 0``, 129 logarithmic ones (as + :func:`pressure_rdw` samples pressure); each sign change between two samples is + refined with Brent's method to a relative tolerance of 2e-12; and a refined + change is kept as a root only where the residual has fallen to a thousandth of + its value at the bracket's ends, which discards a change through a pole (a wall + resonance, where it diverges instead). Roots closer together than one sample + are not separated: narrow `search` to resolve them. Parameters ---------- @@ -1113,22 +1173,6 @@ def solve_parameter( raise ToolsError(f"search must be finite and ordered, got {search!r}") if not math.isfinite(z): raise ToolsError(f"z must be finite, got {z!r}") - return _solve(build, (low, high), target, z=z, narrow="search") - - -def _solve( - build: DesignBuild, - search: tuple[float, float], - target: DesignTarget, - *, - z: float = 0.0, - narrow: str, -) -> DesignRoots: - """Return every root of a target over a finite ordered range: `solve_parameter`. - - - ``narrow``: the keyword a message tells the caller to narrow. - """ - low, high = search builds = 0 def _residual(value: float) -> float | None: @@ -1142,7 +1186,7 @@ def _residual(value: float) -> float | None: return _target_residual(target, mode, gamma, z) sampled = _sampled_roots( - _residual, (low, high), what="the target's residual", narrow=narrow + _residual, (low, high), what="the target's residual", narrow="search" ) # Outside the probing context, so a root beyond an index model's range warns. for root in sampled.roots: diff --git a/tests/test_design_solvers.py b/tests/test_design_solvers.py index d272c6c9..950e381b 100644 --- a/tests/test_design_solvers.py +++ b/tests/test_design_solvers.py @@ -9,7 +9,6 @@ import numpy as np import pytest -from scipy.optimize import brentq from solprop import tools from solprop.constants import C @@ -94,35 +93,20 @@ def test_the_rdw_target_reproduces_pressure_rdw(pulse): assert 256 < roots.builds < 300 -@pytest.mark.parametrize( - ("gas", "radius", "wavelength"), - [("Ar", 125e-6, 500e-9), ("Ar", 125e-6, PUMP), ("He", 100e-6, 500e-9)], -) -def test_pressure_zdw_chooses_the_root_one_brent_bracket_over_the_range_does( - gas, radius, wavelength -): - # The former pressure_zdw: one Brent bracket from the floor to pressure_max, with - # brentq's default xtol of 2e-12 bar. Where the dispersion has one zero in the - # range, the sampled search of solve_parameter must choose the same one. - fill = capillary(gas, radius) - omega = np.array([2.0 * math.pi * C / wavelength]) - with tools_waveguide._probing(): - expected = brentq( - lambda p: base.beta2(tools_design._capillary_at(fill, p), omega)[0] * 1e30, - 1e-6, - 100.0, - ) - found = tools.pressure_zdw(radius, gas, wavelength, cladding=GLASS) - # Both stop inside the band in which beta2, a Richardson difference good to 5e-9 - # (modes.base), is noise, which its slope in pressure carries to the root as a - # few parts in 1e8. - assert found == pytest.approx(expected, rel=1e-6) +def test_the_zdw_target_reproduces_pressure_zdw_where_it_has_one_root(): + expected = tools.pressure_zdw(125e-6, "Ar", 500e-9, cladding=GLASS) + fill = capillary("Ar") roots = solve_parameter( lambda pressure: tools_design._capillary_at(fill, pressure), (1e-6, 100.0), - ZDW(wavelength), + ZDW(500e-9), ) - assert roots.roots == (found,) + assert len(roots) == 1 + # pressure_zdw brackets once and stops at xtol = 2e-12 bar, this search refines + # one sampled bracket: both roots sit inside the band in which beta2, a + # Richardson difference good to 5e-9 (modes.base), is noise, which its slope in + # pressure carries to the root as a few parts in 1e8. + assert roots.pick() == pytest.approx(expected, rel=1e-6) def test_radius_zdw_inverts_the_modes_zero_dispersion_wavelength(): diff --git a/tests/test_soliton_content.py b/tests/test_soliton_content.py index 118f0542..17d37db7 100644 --- a/tests/test_soliton_content.py +++ b/tests/test_soliton_content.py @@ -282,32 +282,17 @@ def test_pressure_search_inverts_constituent_phase_for_both_modes(shape, vincett assert found == pytest.approx(pressure, rel=1e-6) -def sampled(residual, limits): - """Search a residual over a pressure range as pressure_rdw does.""" - return tools_design._sampled_roots( - residual, limits, what="the target's residual", narrow="pressure_limits" - ) - - -def test_pressure_search_rejects_ambiguous_or_inadmissible_roots(monkeypatch): - # The sampled search finds both roots; pressure_rdw refuses to choose. - assert sampled(lambda p: (p - 2) * (p - 3), (1, 4)).roots == pytest.approx( - (2.0, 3.0), rel=1e-12 - ) - target = tools.RDW(PUMP, 200e-9) - found = tools.DesignRoots((2.0, 3.0), (1.0, 4.0), target, 0, "") - monkeypatch.setattr(tools_design, "_solve", lambda *args, **kwargs: found) +def test_pressure_search_rejects_ambiguous_or_inadmissible_roots(): with pytest.raises(ToolsError, match=r"2 roots, at 2, 3 bar"): - tools.pressure_rdw(125e-6, "He", 200e-9, PUMP, cladding=1.45) - monkeypatch.undo() - # A sign change across a region with no soliton is no root. - assert sampled(lambda p: None if 2 < p < 3 else p - 2.5, (1, 4)).roots == () + tools_design._rdw_pressure_root(lambda p: (p - 2) * (p - 3), (1, 4)) + with pytest.raises(ToolsError, match="no pressure"): + tools_design._rdw_pressure_root( + lambda p: None if 2 < p < 3 else p - 2.5, (1, 4) + ) with pytest.raises(ToolsError, match="not finite"): - sampled(lambda p: math.nan, (1, 4)) + tools_design._rdw_pressure_root(lambda p: math.nan, (1, 4)) with pytest.raises(ToolsError, match="ordered"): - tools.pressure_rdw( - 125e-6, "He", 200e-9, PUMP, pressure_limits=(4, 1), cladding=1.45 - ) + tools_design._rdw_pressure_root(lambda p: p, (4, 1)) def test_params_report_constituents_and_normal_dispersion_unavailability(): @@ -347,7 +332,7 @@ def test_pressure_limits_reject_invalid_ranges(limits): def test_pressure_constituent_search_rejects_subthreshold_pulses(): - with pytest.raises(ToolsError, match=r"no value between 0\.1 and 0\.2 gives"): + with pytest.raises(ToolsError, match="no pressure"): tools.pressure_rdw( 125e-6, "He", @@ -395,19 +380,21 @@ def test_unresolved_positive_eigenvalue_is_rejected(): def test_pressure_refinement_refuses_an_unresolved_no_soliton_gap(): root = 2.123456 with pytest.raises(ToolsError, match="crossed a no-soliton region"): - sampled(lambda p: None if abs(p - root) < 1e-5 else p - root, (1.0, 4.0)) + tools_design._rdw_pressure_root( + lambda p: None if abs(p - root) < 1e-5 else p - root, (1.0, 4.0) + ) def test_pressure_search_requires_a_nonempty_bracket(): with pytest.raises(ToolsError, match="pressure_max must exceed"): tools.pressure_rdw(125e-6, "He", 200e-9, PUMP, pressure_max=1e-7) - with pytest.raises(ToolsError, match=r"no value between 1e-06 and 0\.001 gives"): - tools.pressure_zdw(125e-6, "He", 400e-9, pressure_max=1e-3, cladding=1.45) + with pytest.raises(ToolsError, match="no pressure between"): + tools_design._pressure_root(lambda p: 1.0, 100.0, "an unreachable target") @pytest.mark.parametrize( "dispersion, message", - [(math.nan, "dispersion is not finite"), (1e-26, "no value between")], + [(math.nan, "dispersion is not finite"), (1e-26, "no pressure")], ) def test_pressure_search_rejects_invalid_pump_dispersion( monkeypatch, dispersion, message diff --git a/tests/test_tools.py b/tests/test_tools.py index 63b89063..a4a1548a 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -672,7 +672,7 @@ def test_the_pressure_with_the_kerr_term_is_higher(): def test_a_pressure_bracket_without_a_root_is_refused(): - with pytest.raises(ToolsError, match=r"no value between 1e-06 and 0\.5 gives"): + with pytest.raises(ToolsError, match="no pressure between"): tools.pressure_rdw(125e-6, "He", 186e-9, PUMP, pressure_max=0.5, cladding=GLASS) with pytest.raises(ToolsError, match="pressure_max must exceed"): tools.pressure_zdw(125e-6, "He", 400e-9, pressure_max=1e-7, cladding=GLASS) @@ -994,10 +994,9 @@ def test_an_antiresonant_fibre_s_resonances_are_skipped_not_raised(): assert abs(beside[1]) < 1e-3 * max(abs(beside[0]), abs(beside[2])) -def test_a_residual_that_is_not_a_number_is_a_tools_error(monkeypatch): - monkeypatch.setattr(tools_design.base, "beta2", lambda *args: np.array([math.nan])) - with pytest.raises(ToolsError, match="the target's residual is not finite at"): - tools.pressure_zdw(125e-6, "He", 400e-9, pressure_max=10.0, cladding=GLASS) +def test_a_residual_that_is_not_a_number_is_a_tools_error(): + with pytest.raises(ToolsError, match="not finite at the ends"): + tools_design._pressure_root(lambda pressure: math.nan, 10.0, "anything") def test_the_tools_build_the_capillary_the_run_interface_builds(): From 1473e128e442a3900648bebe6774f3d239395b34 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:31:30 +0000 Subject: [PATCH 30/42] Rename driver/service.py to driver/session_loop.py It is SEP 47's session loop, not solprop.service. The private helpers it imported from driver/run.py that only it uses move with it (_announce, _earlier_execution, _method_history, _record_abandoned_step, _record_execution, _refuse_unread_collar, _resume_position, _retain, _FailedObservation, _MarchKeeper, _Status), and with _MarchKeeper the MARCH_SNAPSHOT_AUTO_BYTES limit it reads; the two run.py also uses become public there: history_of and open_run. Co-Authored-By: Claude Opus 5.5 --- docs/dev/backends.md | 2 +- docs/dev/benchmarks.md | 2 +- docs/dev/figures/build.py | 2 +- docs/dev/figures/layers.svg | 2 +- src/solprop/backends/session.py | 6 +- src/solprop/driver/_scan_batch.py | 4 +- src/solprop/driver/run.py | 566 ++---------------- .../driver/{service.py => session_loop.py} | 561 ++++++++++++++++- src/solprop/driver/settings.py | 6 +- tests/driver/snapshot_worker.py | 4 +- tests/driver/test_restart_record.py | 5 +- tests/driver/test_resume_history.py | 6 +- tests/driver/test_run.py | 6 +- tests/driver/test_snapshots.py | 4 +- tests/output/test_callback.py | 2 +- tests/output/test_snapshot_failures.py | 4 +- tests/sessions/test_destination_export.py | 2 +- tests/sessions/test_native_session_monitor.py | 2 +- tests/test_monitor.py | 2 +- 19 files changed, 608 insertions(+), 580 deletions(-) rename src/solprop/driver/{service.py => session_loop.py} (62%) diff --git a/docs/dev/backends.md b/docs/dev/backends.md index da62d8d9..e9e94129 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -335,7 +335,7 @@ rows in threads, as `stored_spectrum` does. A destination is validated by one ru `destination_refusal`, which names what is wrong: the shape, the dtype, the layout (one whose elements overlap one another is not contiguous) or a read-only array. -The service loop (`driver/service.py`, `_Exports`) asks for one export per pending ticket +The session loop (`driver/session_loop.py`, `_Exports`) asks for one export per pending ticket and shares it among the plane's consumers. A plane store that `takes_exports` gives the export its own room with `field_destination`: `MemoryOutput` the block row the plane will occupy, allocated with the reserved planes, so a kept field is written once, where it is diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index b43d5cde..fb7a362a 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2330,7 +2330,7 @@ backend's host copy, which every kept plane makes): | driver session, file | the observation and a new stored array | the observation, converted where it is | **Through the service loop.** Each `sep47` saved case run to its end through -`solprop.driver.service.serve` (a warm-up, three timed runs, one under `tracemalloc`), +`solprop.driver.session_loop.serve` (a warm-up, three timed runs, one under `tracemalloc`), timing each kept plane from its export to the end of its store, and tracing the bytes allocated in that window above its start (NumPy's allocations; the native observation is outside the tracer's sight). The median over planes 2 to 5; the first allocates the diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 7ddbf936..1995f859 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -819,7 +819,7 @@ class Layer(NamedTuple): "defaults", "estimate", "plane_view", - "service", + "session_loop", ), ), Module( diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index b210c76b..51c54fe0 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -240,7 +240,7 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: material_output · tdse tdse_consistency · scan · batch defaults · estimate · plane_view -service +session_loop recipe/ core · document · errors · export diff --git a/src/solprop/backends/session.py b/src/solprop/backends/session.py index 17ed172b..d34ae5cb 100644 --- a/src/solprop/backends/session.py +++ b/src/solprop/backends/session.py @@ -11,9 +11,9 @@ ``Backend.prepare``), and the prepared execution starts one :class:`RunSession` per trajectory. The session is the only owner of that trajectory's mutable numerical state: the field, the method's history, the controller and monitor, -the counters and cursors, and the pending events. Python's service loop -(:mod:`solprop.driver.service`) advances it, services the events it yields -- files, -callbacks, warnings, progress -- and acknowledges them:: +the counters and cursors, and the pending events. Python's session loop +(:mod:`solprop.driver.session_loop`) advances it, services the events it yields -- +files, callbacks, warnings, progress -- and acknowledges them:: prepared = backend.prepare(problem, request) with prepared.start(start, stop=token) as session: diff --git a/src/solprop/driver/_scan_batch.py b/src/solprop/driver/_scan_batch.py index 7a816f95..318d59a3 100644 --- a/src/solprop/driver/_scan_batch.py +++ b/src/solprop/driver/_scan_batch.py @@ -37,7 +37,7 @@ from solprop.driver.batch import BatchMember, compatible_groups, shared_table_bytes from solprop.driver.executors.batch import Batch from solprop.driver.executors.pool import _memory_limit, peak_rss -from solprop.driver.run import _history_of +from solprop.driver.run import history_of from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError from solprop.output.scan import PointResult @@ -196,7 +196,7 @@ def _plane( z=event.z, field=session.export(member, event.ticket, _CONSUMER) if keep else None, stats=dict(event.stats), - step_history=_history_of(event.history), + step_history=history_of(event.history), step_stats=StepStats(z=rows.z, step=rows.step, values=dict(rows.values)), noise_phase=session.noise_phase(member, event.ticket) if keep else None, ) diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index 53dc7e9c..1108c141 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -28,21 +28,18 @@ The manual page is ``docs/guide/setup/running.md``. """ -import dataclasses import logging import warnings from collections.abc import Mapping, Sequence -from typing import Literal, cast +from typing import cast import numpy as np -from solprop._host import RunClock, machine, usage -from solprop._interrupts import committing, handled +from solprop._interrupts import handled from solprop.backends.populations import axis_refusal -from solprop.backends.protocol import Backend, BackendHistory, HostStepping, Observation +from solprop.backends.protocol import Backend, BackendHistory, Observation from solprop.backends.registry import statistics_refusal from solprop.backends.session import RunSession, Start, statistics_unavailable -from solprop.driver.summary import run_summary from solprop.exceptions import ( BackendError, DriverError, @@ -55,10 +52,9 @@ ProgressChoice, ProgressReporter, StopFlag, - SummarisingReporter, resolve_reporter, ) -from solprop.monitor import BoundaryCheck, BoundaryMonitor +from solprop.monitor import BoundaryMonitor from solprop.output.capability import ( capability_of, declared_capability, @@ -67,22 +63,18 @@ ) from solprop.output.hdf5 import HDF5Output from solprop.output.schema import ( - RUN_SUMMARY_ANNOTATION, BackendRecord, CheckpointOutput, FieldRetention, FieldsChoice, MarchSnapshot, Output, - PlaneStore, RecipeRecord, RetainingOutput, RunMetadata, - RunOutcome, SavedPlane, StepHistory, StepStats, - TerminalCheckpoint, field_retention_refusal, ) from solprop.output.snapshots import SnapshotController @@ -91,18 +83,19 @@ from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.stepper.multistep import Multistep from solprop.translate.problem import PropagationProblem -from solprop.translate.routes import tdse_descriptor, transverse_of +from solprop.translate.routes import tdse_descriptor from solprop.translate.run_request import HistoryPolicy, RunRequest from solprop.translate.tdse import TDSEBinding __all__ = [ - "MARCH_SNAPSHOT_AUTO_BYTES", "RESTART_RECORD", "LogReporter", "ProgressReporter", "SessionFacts", "check_snapshot_request", "field_retention", + "history_of", + "open_run", "open_run_record", "plane_from_observation", "retention_at", @@ -111,16 +104,8 @@ logger = logging.getLogger(__name__) -MARCH_SNAPSHOT_AUTO_BYTES = 256 * 2**20 -"""The largest Adams march snapshot a run keeps when ``resume_history`` is ``None``. -The snapshot -- the state and the history's rows, a few to sixty copies of the state -- -is rewritten at every save plane; a state too large for that to be cheap keeps none -unless asked to, and its resume starts the history afresh. -""" - - -def _history_of(history: BackendHistory) -> StepHistory: +def history_of(history: BackendHistory) -> StepHistory: """Return a backend's step history in the form an output handler stores. The two records carry the same five columns, and two of them mean different things @@ -132,6 +117,16 @@ def _history_of(history: BackendHistory) -> StepHistory: row. The conversion is made here: the start is the end less the length, to round-off, and the per-attempt times are the differences of the clock, the first from zero. + + Parameters + ---------- + history : solprop.backends.protocol.BackendHistory + The attempts a backend reports, each ending at its ``z``. + + Returns + ------- + solprop.output.schema.StepHistory + The same attempts, each starting at its ``z``, with per-attempt times. """ return StepHistory( z=history.z - history.dz, @@ -195,7 +190,7 @@ def plane_from_observation( z=observation.z, field=observation.field if field else None, stats={name: np.asarray(value) for name, value in values.items()}, - step_history=_history_of(observation.step_history), + step_history=history_of(observation.step_history), step_stats=StepStats(z=rows.z, step=rows.step, values=dict(rows.values)), noise_phase=observation.noise_phase if field else None, ) @@ -375,7 +370,7 @@ def run_problem( Whether an output that can keep it (``save_march``) keeps the Adams march's state at every save plane and terminal checkpoint, so that a resume continues the history instead of starting it afresh. ``None`` keeps it while it is at - most :data:`MARCH_SNAPSHOT_AUTO_BYTES`. + most :data:`~solprop.driver.session_loop.MARCH_SNAPSHOT_AUTO_BYTES`. fields : {'all', 'planes', 'none'}, optional Which saved planes keep their field (SEP 22): every one (the default), those of `field_planes`, or none. A plane that keeps none stores its position, its @@ -486,7 +481,7 @@ def run_problem( # The session and its service loop (SEP 47): imported here, since the service # loop builds on this module's output transactions. from solprop.backends.sessions import start_session # noqa: PLC0415 - from solprop.driver.service import ServiceContext, serve # noqa: PLC0415 + from solprop.driver.session_loop import ServiceContext, serve # noqa: PLC0415 request = prepared.for_run( stats=spec, @@ -569,50 +564,6 @@ def _restored( return {**restoration, "state": "reset", "reason": "incompatible_representation"} -def _announce( # noqa: PLR0917 - called positionally - problem: PropagationProblem, - metadata: RunMetadata, - backend: Backend | SessionFacts, - output: Output, - reporter: ProgressReporter, - start: tuple[int, float], - *, - request: RunRequest, - boundary_check: BoundaryCheck | None = None, -) -> None: - """Show the run's summary through the reporter and record it on a plane store. - - - ``reporter``: where progress goes; only a :class:`solprop.log.SummarisingReporter` - is shown the summary. - - ``start``: the first save plane beyond the loaded state, and the position the run - starts from, m. - - ``boundary_check``: the thresholds the window's edges are watched with, or - ``None``. - """ - start_plane, z_start = start - summary = run_summary( - problem, - request, - metadata, - backend=backend.name, - execution=backend.metadata(), - output=output, - start_plane=start_plane, - z_start=z_start, - boundary_check=boundary_check, - ) - if isinstance(reporter, SummarisingReporter): - reporter.summary(summary.text()) - if isinstance(output, PlaneStore): - # An annotation stays outside the record and its hash, so a summary needs no - # schema of its own; a resume adds its continuation's to the list. - earlier = output.annotations.get(RUN_SUMMARY_ANNOTATION, []) - output.annotate( - RUN_SUMMARY_ANNOTATION, - [*cast("list[object]", earlier), summary.record()], - ) - - def check_snapshot_request( request: RunRequest, output: Output, @@ -725,20 +676,6 @@ def field_retention( raise DriverError(str(error)) from error -def _retain( - output: Output, retention: FieldRetention, field_shape: tuple[int, ...] -) -> None: - """Tell an output that can hold it which planes keep their field. - - Called once the output is open, so that a file being continued compares the rule - with the one it holds. - - - ``field_shape``: ``(n_modes, n_pol, n_omega)`` of the run's state. - """ - if isinstance(output, RetainingOutput): - output.retain(retention, field_shape) - - def _check_retainable(output: Output, retention: FieldRetention) -> None: """Refuse a field-free rule for an output that cannot hold one. @@ -755,120 +692,29 @@ def _check_retainable(output: Output, retention: FieldRetention) -> None: raise DriverError(msg) -def _resume_position( - output: Output, - checkpoint_output: CheckpointOutput | None, - problem: PropagationProblem, - request: RunRequest, -) -> tuple[float, bool]: - """Resolve the actual checkpoint position and any scheduled observation owed. - - - ``checkpoint_output``: Checkpoint-capable handler, if available, holding any - terminal state. - - ``problem``: translated problem with the absolute launch position, m. - - ``request``: the run's request, with the scheduled save positions, m. - - Returns: actual restart position in m and whether its scheduled observation still - needs saving because failure occurred before the regular save completed. - """ - reached = float(output["z"][-1]) if output.n_saved else problem.initial.z - if ( - checkpoint_output is not None - and checkpoint_output.terminal_checkpoint is not None - ): - reached = checkpoint_output.terminal_checkpoint.plane.z - missing_current_save = ( - checkpoint_output is not None - and checkpoint_output.terminal_checkpoint is not None - and bool(np.any(request.observation.save_planes == reached)) - and (output.n_saved == 0 or float(output["z"][-1]) < reached) - ) - return reached, missing_current_save - - -def _refuse_unread_collar( - problem: PropagationProblem, backend: Backend | SessionFacts -) -> None: - """Refuse a requested collar on a backend that does not read its return (SEP 40). - - A requested real-space collar is judged by what it returns, which the monitor's - transverse edge reads; a backend that does not read it refuses the collar by name - before the first step (SEP 40 section 6). Its in-stage rate is not the reason: - the backends compute it alike. - """ - design = transverse_of(problem.route) - if design is None or not design.has_collar: - return - if backend.reads_transverse_edge: - return - msg = ( - f"the {backend.name} backend does not carry a requested real-space collar " - "(SpatialAbsorber with real_space=True): run it on one of solprop's own " - "backends, or ask for the safety ramp alone" - ) - raise BackendError(msg) - - -def _earlier_execution( - output: Output, checkpoint_output: CheckpointOutput | None -) -> tuple[bool, Mapping[str, object] | None, tuple[tuple[float, float], ...]]: - """Return whether this execution resumes another, and the monitor record to go on. - - - ``checkpoint_output``: Checkpoint-capable handler, if available, holding the - earlier outcome. - - Returns ``record``: the latest earlier execution's boundary-monitor record, - searching back past executions that kept none, or ``None`` when none did. - - Returns ``unwatched``: the stretches, ``(start, end)`` in m, that executions after - that record ran without a monitor; from the launch when no execution kept one. - """ - outcome = None if checkpoint_output is None else checkpoint_output.outcome - resumed = output.n_saved > 0 or outcome is not None - if outcome is None: - return resumed, None, () - # Earliest first: each earlier execution's closing record, then the latest. - chain: list[tuple[float, Mapping[str, object]]] = [] - for previous in outcome.previous_executions: - execution = previous.get("execution") - reached = previous.get("z") - chain.append( - ( - float(reached) if isinstance(reached, int | float) else 0.0, - cast("Mapping[str, object]", execution) - if isinstance(execution, Mapping) - else {}, - ) - ) - chain.append((float(outcome.z), outcome.execution)) - # The latest record that holds a monitor; executions after it ran unwatched, - # each from where the one before it stopped to where it stopped itself, which is - # where the next resumed. A hard kill before the monitor is attached - # leaves a record without one and an empty stretch, and is passed over. - found = -1 - for index in range(len(chain) - 1, -1, -1): - if isinstance(chain[index][1].get("boundary_monitor"), Mapping): - found = index - break - unwatched: list[tuple[float, float]] = [] - start = 0.0 if found < 0 else chain[found][0] - for reached, _ in chain[found + 1 :]: - if reached > start: - unwatched.append((start, reached)) - start = max(start, reached) - if found < 0: - return resumed, None, tuple(unwatched) - record = cast("Mapping[str, object]", chain[found][1]["boundary_monitor"]) - return resumed, record, tuple(unwatched) - - -def _open_run( +def open_run( output: Output, metadata: RunMetadata, backend: Backend | SessionFacts, binding: TDSEBinding | None, ) -> CheckpointOutput | None: - """Attach actual runtime facts outside the scientific resume identity. + """Open a run's output with the runtime facts outside its scientific identity. + + Parameters + ---------- + output : solprop.output.schema.Output + The handler, not yet open. + metadata : solprop.output.schema.RunMetadata + The run's record, without its backend. + backend : solprop.backends.protocol.Backend or SessionFacts + What executes the run, whose metadata the record's backend section takes. + binding : solprop.translate.tdse.TDSEBinding or None + A TDSE response's binding, whose metadata the record carries too. - - Returns: the handler's checkpoint interface, or ``None`` for a handler without - one. + Returns + ------- + solprop.output.schema.CheckpointOutput or None + The handler's checkpoint interface, or ``None`` for a handler without one. """ # A session that holds its monitor from the start (the native session) reports # the launch's zeros here. The execution record carries the monitor instead @@ -909,19 +755,7 @@ def open_run_record( binding : TDSEBinding or None Optional atomic service supplying execution provenance. """ - _open_run(output, metadata, backend, binding) - - -@dataclasses.dataclass(frozen=True) -class _FailedObservation: - """Retain drained history and writer progress when a scheduled save fails.""" - - pending: SavedPlane | None - already_saved: bool - error: BaseException - reached: float - clock: RunClock - last_kept_field: bool + open_run(output, metadata, backend, binding) RESTART_RECORD = "restart" @@ -930,29 +764,13 @@ class _FailedObservation: Optional, under the output compatibility rules: ``"retention"``, what the run kept of its method history at the record's position (``_MarchKeeper``), and, in a resumed execution, ``"restoration"`` (what it restored, -``Simulation._continue_march``) and ``"monitor"`` (how +``solprop.driver.resume._continue_march``) and ``"monitor"`` (how the boundary monitor was restored). Each decision is a mapping with a ``"state"`` and a ``"reason"``. A file written before this record has none, which a reader takes as unknown, never as a reason (``retention_at``). """ -def _method_history( - request: RunRequest, -) -> Literal["adams", "rk", "low_storage"]: - """Return which kind of method history a run's march keeps. - - An Adams history a resume can continue; Runge-Kutta controller memory (the proposal - and the norm memory), which a resume resets; or none at all. - """ - integrator = request.integration.method - if isinstance(integrator, Multistep): - return "adams" - if isinstance(integrator, LowStorageRungeKutta): - return "low_storage" - return "rk" - - def retention_at( execution: Mapping[str, object] | None, z: float ) -> Mapping[str, object] | None: @@ -984,310 +802,6 @@ def retention_at( return retention if retention.get("z") == z else None -class _MarchKeeper: - """Keep the Adams march's state beside each save, and record what was kept. - - Every persisted plane and checkpoint states whether its method history was - retained, omitted or is not applicable, and why (SEP 47 section 5.5); the run's - execution record carries the latest decision (:meth:`record`). - - - ``output``: the run's handler; only one with ``save_march`` keeps a snapshot. - - ``retention``: the decision already recorded at the position the run starts - from, carried into this execution's record until it persists a position of its - own. - """ - - def __init__( - self, - output: Output, - backend: HostStepping | SessionFacts, - policy: bool | None, - *, - method: Literal["adams", "rk", "low_storage"] = "adams", - retention: Mapping[str, object] | None = None, - ) -> None: - """Hold what keeping a snapshot needs.""" - self._save = getattr(output, "save_march", None) - self._snapshot = backend.march_snapshot - self._snapshot_nbytes = backend.march_snapshot_nbytes - self._refusal = backend.march_export_refusal - self._policy = policy - self._method = method - # Measured once against the automatic limit: kept from then on, or not. - self._over_limit = False - self._within_limit = False - self._kept_z: float | None = None - self._decision: dict[str, object] | None = ( - None if retention is None else dict(retention) - ) - - def record(self) -> dict[str, object] | None: - """Return the latest retention decision, for the execution record. - - - Returns: ``"state"`` (``"retained"``, ``"omitted"`` or ``"not_applicable"``), - ``"reason"`` and ``"z"``, m, with ``"nbytes"`` where the size was measured; - ``None`` before anything was persisted. - """ - return None if self._decision is None else dict(self._decision) - - def _decide(self, z: float, state: str, reason: str, **sizes: int) -> None: - """Record a retention decision at `z`. - - - ``z``: the persisted position, m. - - ``state``: ``"retained"``, ``"omitted"`` or ``"not_applicable"``. - - ``**sizes``: ``nbytes``, the history's size where it was measured, and - ``limit``, the automatic limit it was held to, bytes. - """ - self._decision = {"state": state, "reason": reason, "z": z, **sizes} - - def keep(self, z: float, *, required: bool = False) -> None: # noqa: PLR0911, PLR0912 - one return per decision - """Keep the march's state if it stands at `z`, the position just saved. - - - ``z``: the saved plane's or the checkpoint's position, m. - - ``required``: an immutable snapshot needs the full Adams state, so the - automatic retention-size cutoff is ignored and a history that cannot be - persisted here is refused. - """ - if required and self._kept_z == z: - return - if self._method == "low_storage": - self._decide(z, "not_applicable", "no_method_history") - return - if self._method == "rk": - # The proposal and the norm memory are not persisted (issue #596). - self._decide(z, "omitted", "unsupported_method") - return - if self._save is None: - self._decide(z, "omitted", "output_cannot_store") - if required: - raise OutputError( - "snapshot requires a backend and output with Adams history" - ) - return - if self._policy is False and not required: - self._decide(z, "omitted", "explicit_policy") - return - if self._over_limit and not required: - self._decide( - z, "omitted", "automatic_size_limit", limit=MARCH_SNAPSHOT_AUTO_BYTES - ) - return - if self._policy is None and not required and not self._within_limit: - # Before any copy: the snapshot over the limit is the state and the whole - # history, on the host. - nbytes = self._snapshot_nbytes(z) - if nbytes is None: - self._omit_unavailable(z) - return - if nbytes > MARCH_SNAPSHOT_AUTO_BYTES: - logger.info( - "the Adams march's state is not kept for a resume: %d bytes is " - "over the %d kept by default (resume_history=True keeps it)", - nbytes, - MARCH_SNAPSHOT_AUTO_BYTES, - ) - self._over_limit = True - self._decide( - z, - "omitted", - "automatic_size_limit", - nbytes=nbytes, - limit=MARCH_SNAPSHOT_AUTO_BYTES, - ) - return - self._within_limit = True - snapshot = self._snapshot() - if snapshot is None or snapshot.z != z: - self._omit_unavailable(z) - if required: - raise OutputError( - "snapshot requires Adams history at the accepted endpoint" - ) - return - try: - self._save(snapshot) - except BaseException: - # A torn write reads as no snapshot (`_write_march`): expected, and lost. - self._decide(z, "omitted", "write_failed") - raise - self._kept_z = z - self._decide( - z, - "retained", - "snapshot_required" - if required - else "explicit_policy" - if self._policy - else "automatic_policy", - nbytes=snapshot.nbytes, - ) - - def _omit_unavailable(self, z: float) -> None: - """Record history that could not be had at `z`, naming an engine's refusal. - - - ``z``: the persisted position, m. - """ - refused = self._refusal() is not None - self._decide( - z, - "omitted", - "incompatible_representation" if refused else "unavailable_history", - ) - - -def _record_abandoned_step( - backend: Backend | SessionFacts, - output: CheckpointOutput, - binding: TDSEBinding | None, - failure: _FailedObservation, - step: tuple[float, float], - *, - restart: Mapping[str, object] | None = None, -) -> float | None: - """Record a failure that left a low-storage step in flight, with no new checkpoint. - - The low-storage march accepts each step in place (SEP 24 section 6.2), so a step - abandoned between its start and its commit leaves a state that is neither of the - step's ends, which the backend refuses to observe, so nothing honest can be written - as a terminal checkpoint. The file falls back to the latest state it holds beyond - its last saved plane, a running checkpoint (SEP 47 section 5.5) or the checkpoint - this execution resumed from, which is kept; without one it ends at its last saved - plane: a resume continues from one that kept its field, to rounding, with - ``step_planes`` bounding what is lost, and refuses one that did not. - - - ``step``: where the abandoned step started and its length, m. - - Returns: the position of the checkpoint fallen back to, m, or ``None`` without - one. - """ - z, dz = step - fallback = output.terminal_checkpoint - if fallback is not None: - ending = ( - f"the file falls back to its latest checkpoint, z = " - f"{fallback.plane.z:.6g} m, " - + ( - "from which resume=True continues" - if fallback.landed - else "an interpolated field, from which only " - "resume='interpolated' continues" - ) - ) - else: - kept = ( - "from which resume=True continues" - if failure.last_kept_field - else "which kept no field, so this file cannot be resumed; rerun it, " - "keeping a plane's field" - ) - ending = ( - f"the file ends at its last saved plane, z = {failure.reached:.6g} m, " - f"{kept}" - ) - reason = ( - f"no terminal checkpoint is written: the low-storage step of {dz:.6g} m from " - f"z = {z:.6g} m was abandoned in flight, so the state holds neither end of " - f"it; {ending}" - ) - failure.error.add_note(reason) - logger.warning("%s", reason) - try: - with committing(deliver=False): - _record_execution( - output, - backend, - binding, - _Status( - "failed", - failure.reached if fallback is None else fallback.plane.z, - f"{type(failure.error).__name__}: {failure.error}; {reason}", - ), - clock=failure.clock, - checkpoint=fallback, - restart=restart, - ) - except Exception as record_error: - failure.error.add_note(f"the failure could not be recorded: {record_error}") - logger.exception("could not record the failure") - return None - return None if fallback is None else fallback.plane.z - - -@dataclasses.dataclass(frozen=True) -class _Status: - """What an execution record says of the run, before its accounting is added. - - Attributes - ---------- - status : {'running', 'complete', 'budget_exhausted', 'failed'} - The run's status (:class:`~solprop.output.schema.RunOutcome`). - z : float - Last accepted or saved position, m. - message : str - Explanation of a stop or failure; empty on ordinary progress. - """ - - status: Literal["running", "complete", "budget_exhausted", "failed"] - z: float - message: str = "" - - -def _record_execution( - output: CheckpointOutput | None, - backend: Backend | SessionFacts, - binding: TDSEBinding | None, - status: _Status, - *, - clock: RunClock, - checkpoint: TerminalCheckpoint | None = None, - restart: Mapping[str, object] | None = None, -) -> None: - """Snapshot call accounting without requesting any diagnostic evaluation. - - The record also names the machine (:func:`solprop._host.machine`) and what the run - has spent so far (:func:`solprop._host.usage`): written at every save plane, it - keeps the wall time and peak memory of a run that is killed between them. - - - ``status``: requested status, absolute position in m and any error message. - - ``restart``: the execution's restart record (:data:`RESTART_RECORD`); ``None`` or - an empty one records none. - """ - if output is None: - return - record = {**backend.metadata(), "host": machine(), "resources": usage(clock)} - if restart: - record[RESTART_RECORD] = dict(restart) - monitor = record.get("boundary_monitor") - if status.status == "running" and isinstance(monitor, Mapping): - # The monitor's per-plane rows go out with the closing record alone: rewritten - # at every plane, the record grew with them and the file with their square. - record["boundary_monitor"] = { - name: value for name, value in monitor.items() if name != "planes" - } - if binding is not None: - record = {**record, "tdse": dict(binding.metadata())} - calls = record.get("response_calls", {}) - logical = cast("Mapping[str, object]", calls) if isinstance(calls, Mapping) else {} - atomic = ( - record.get("atomic_calls", {}) - if binding is None - else dataclasses.asdict(binding.ledger) - ) - # Built once, so that a saved plane validates its record once (RunOutcome). - output.record_outcome( - RunOutcome( - status.status, - status.z, - status.message, - logical_calls=logical, - atomic_calls=cast("Mapping[str, object]", atomic) - if isinstance(atomic, Mapping) - else {}, - execution=record, - ), - checkpoint, - ) - - def _replayed_steps(request: RunRequest, start_plane: int) -> int | None: """Return the step count a replayed schedule will take, when it is knowable. diff --git a/src/solprop/driver/service.py b/src/solprop/driver/session_loop.py similarity index 62% rename from src/solprop/driver/service.py rename to src/solprop/driver/session_loop.py index ec906250..21306d70 100644 --- a/src/solprop/driver/service.py +++ b/src/solprop/driver/session_loop.py @@ -2,7 +2,9 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""The service loop: Python's side of an execution session (SEP 47 section 5). +"""The session loop: Python's side of an execution session (SEP 47 section 5). + +Not to be confused with :mod:`solprop.service`, the front end's service. A session (:mod:`solprop.backends.session`) owns the run's numerical state and its schedule. This loop advances it and services what it reports -- saved planes and their @@ -25,11 +27,11 @@ import time from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import cast +from typing import Literal, cast import numpy as np -from solprop._host import RunClock, usage +from solprop._host import RunClock, machine, usage from solprop._interrupts import committing, poll from solprop.backends.events import ( EventBatch, @@ -45,29 +47,25 @@ ) from solprop.backends.export import ExportRecipe from solprop.backends.host.session import HostSession -from solprop.backends.protocol import BackendHistory, HostStepping +from solprop.backends.protocol import Backend, BackendHistory, HostStepping from solprop.backends.protocol import StepStats as BackendRows from solprop.backends.session import RunSession from solprop.driver.plane_view import PlaneWatch from solprop.driver.run import ( + RESTART_RECORD, SessionFacts, - _announce, - _earlier_execution, - _FailedObservation, - _history_of, - _MarchKeeper, - _method_history, - _open_run, - _record_abandoned_step, - _record_execution, - _refuse_unread_collar, - _resume_position, - _retain, - _Status, + history_of, + open_run, retention_at, ) -from solprop.driver.summary import resources -from solprop.exceptions import ResponseBudgetError, RunTerminated, StopRequested +from solprop.driver.summary import resources, run_summary +from solprop.exceptions import ( + BackendError, + OutputError, + ResponseBudgetError, + RunTerminated, + StopRequested, +) from solprop.fourier import GridKind, spectrum_to_internal from solprop.log import ( InterruptibleReporter, @@ -75,14 +73,18 @@ StopFlag, SummarisingReporter, ) +from solprop.monitor import BoundaryCheck from solprop.output.hdf5 import HDF5Output from solprop.output.schema import ( + RUN_SUMMARY_ANNOTATION, CheckpointOutput, FieldRetention, Output, PlaneStore, RecipeRecord, + RetainingOutput, RunMetadata, + RunOutcome, SavedPlane, StepHistory, StepStats, @@ -92,15 +94,30 @@ ) from solprop.output.snapshots import SnapshotController from solprop.output.tdse_archive import TDSEArchiveWriter +from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.stepper.multistep import Multistep from solprop.translate.problem import PropagationProblem +from solprop.translate.routes import transverse_of from solprop.translate.run_request import RunRequest from solprop.translate.tdse import TDSEBinding -__all__ = ["MONITOR_RECONSTRUCTED", "ServiceContext", "serve"] +__all__ = [ + "MARCH_SNAPSHOT_AUTO_BYTES", + "MONITOR_RECONSTRUCTED", + "ServiceContext", + "serve", +] logger = logging.getLogger("solprop.driver.run") +MARCH_SNAPSHOT_AUTO_BYTES = 256 * 2**20 +"""The largest Adams march snapshot a run keeps when ``resume_history`` is ``None``. + +The snapshot -- the state and the history's rows, a few to sixty copies of the state -- +is rewritten at every save plane; a state too large for that to be cheap keeps none +unless asked to, and its resume starts the history afresh. +""" + _CONSUMER = ExportRecipe(power=True, optics=False) """The field a plane observer, a checkpoint record and an output that is not a plane store hold: output amplitudes in the internal FFT convention (SEP 47 section 5.4).""" @@ -142,7 +159,7 @@ class ServiceContext: The run's stop flag, polled between batches as well as inside the session. restoration : collections.abc.Mapping or None, optional What a resumed execution restored of the method history - (``Simulation._continue_march``), for its + (``solprop.driver.resume._continue_march``), for its execution record; ``None`` for a fresh run or a caller that says nothing. """ @@ -354,7 +371,7 @@ def _plane( z=event.z, field=None if field is None else _adopted(field), stats=dict(event.stats), - step_history=_history_of(attempts[0]), + step_history=history_of(attempts[0]), step_stats=_rows(attempts[1]), noise_phase=None if phase is None else _adopted(phase), ) @@ -437,7 +454,7 @@ def serve( # noqa: PLR0912, PLR0915 - the commit sequence of SEP 47 section 5.3 planes = context.request.observation.save_planes started = time.perf_counter() clock = RunClock.start() - checkpoint_output = _open_run( + checkpoint_output = open_run( output, context.metadata, backend, context.tdse_binding ) if context.recipe is not None and isinstance(output, PlaneStore): @@ -744,7 +761,7 @@ def persist() -> None: z=z, field=_adopted(exports.field(event.ticket)), stats={}, - step_history=_history_of(history), + step_history=history_of(history), step_stats=_rows(rows), noise_phase=None if phase is None else _adopted(phase), ), @@ -830,7 +847,7 @@ def _persist_failure( # noqa: PLR0917 - the failure's whole context stats={}, step_history=StepHistory.empty() if history is None - else _history_of(history), # pyright: ignore[reportArgumentType] + else history_of(history), # pyright: ignore[reportArgumentType] step_stats=StepStats.empty() if step_rows is None else _rows(step_rows), # pyright: ignore[reportArgumentType] noise_phase=None if phase is None else _adopted(phase), ) @@ -924,3 +941,497 @@ def _facts(session: RunSession) -> HostStepping | SessionFacts: if isinstance(session, HostSession): return session.driver return SessionFacts(session) + + +def _announce( # noqa: PLR0917 - called positionally + problem: PropagationProblem, + metadata: RunMetadata, + backend: Backend | SessionFacts, + output: Output, + reporter: ProgressReporter, + start: tuple[int, float], + *, + request: RunRequest, + boundary_check: BoundaryCheck | None = None, +) -> None: + """Show the run's summary through the reporter and record it on a plane store. + + - ``reporter``: where progress goes; only a :class:`solprop.log.SummarisingReporter` + is shown the summary. + - ``start``: the first save plane beyond the loaded state, and the position the run + starts from, m. + - ``boundary_check``: the thresholds the window's edges are watched with, or + ``None``. + """ + start_plane, z_start = start + summary = run_summary( + problem, + request, + metadata, + backend=backend.name, + execution=backend.metadata(), + output=output, + start_plane=start_plane, + z_start=z_start, + boundary_check=boundary_check, + ) + if isinstance(reporter, SummarisingReporter): + reporter.summary(summary.text()) + if isinstance(output, PlaneStore): + # An annotation stays outside the record and its hash, so a summary needs no + # schema of its own; a resume adds its continuation's to the list. + earlier = output.annotations.get(RUN_SUMMARY_ANNOTATION, []) + output.annotate( + RUN_SUMMARY_ANNOTATION, + [*cast("list[object]", earlier), summary.record()], + ) + + +def _retain( + output: Output, retention: FieldRetention, field_shape: tuple[int, ...] +) -> None: + """Tell an output that can hold it which planes keep their field. + + Called once the output is open, so that a file being continued compares the rule + with the one it holds. + + - ``field_shape``: ``(n_modes, n_pol, n_omega)`` of the run's state. + """ + if isinstance(output, RetainingOutput): + output.retain(retention, field_shape) + + +def _resume_position( + output: Output, + checkpoint_output: CheckpointOutput | None, + problem: PropagationProblem, + request: RunRequest, +) -> tuple[float, bool]: + """Resolve the actual checkpoint position and any scheduled observation owed. + + - ``checkpoint_output``: Checkpoint-capable handler, if available, holding any + terminal state. + - ``problem``: translated problem with the absolute launch position, m. + - ``request``: the run's request, with the scheduled save positions, m. + - Returns: actual restart position in m and whether its scheduled observation still + needs saving because failure occurred before the regular save completed. + """ + reached = float(output["z"][-1]) if output.n_saved else problem.initial.z + if ( + checkpoint_output is not None + and checkpoint_output.terminal_checkpoint is not None + ): + reached = checkpoint_output.terminal_checkpoint.plane.z + missing_current_save = ( + checkpoint_output is not None + and checkpoint_output.terminal_checkpoint is not None + and bool(np.any(request.observation.save_planes == reached)) + and (output.n_saved == 0 or float(output["z"][-1]) < reached) + ) + return reached, missing_current_save + + +def _refuse_unread_collar( + problem: PropagationProblem, backend: Backend | SessionFacts +) -> None: + """Refuse a requested collar on a backend that does not read its return (SEP 40). + + A requested real-space collar is judged by what it returns, which the monitor's + transverse edge reads; a backend that does not read it refuses the collar by name + before the first step (SEP 40 section 6). Its in-stage rate is not the reason: + the backends compute it alike. + """ + design = transverse_of(problem.route) + if design is None or not design.has_collar: + return + if backend.reads_transverse_edge: + return + msg = ( + f"the {backend.name} backend does not carry a requested real-space collar " + "(SpatialAbsorber with real_space=True): run it on one of solprop's own " + "backends, or ask for the safety ramp alone" + ) + raise BackendError(msg) + + +def _earlier_execution( + output: Output, checkpoint_output: CheckpointOutput | None +) -> tuple[bool, Mapping[str, object] | None, tuple[tuple[float, float], ...]]: + """Return whether this execution resumes another, and the monitor record to go on. + + - ``checkpoint_output``: Checkpoint-capable handler, if available, holding the + earlier outcome. + - Returns ``record``: the latest earlier execution's boundary-monitor record, + searching back past executions that kept none, or ``None`` when none did. + - Returns ``unwatched``: the stretches, ``(start, end)`` in m, that executions after + that record ran without a monitor; from the launch when no execution kept one. + """ + outcome = None if checkpoint_output is None else checkpoint_output.outcome + resumed = output.n_saved > 0 or outcome is not None + if outcome is None: + return resumed, None, () + # Earliest first: each earlier execution's closing record, then the latest. + chain: list[tuple[float, Mapping[str, object]]] = [] + for previous in outcome.previous_executions: + execution = previous.get("execution") + reached = previous.get("z") + chain.append( + ( + float(reached) if isinstance(reached, int | float) else 0.0, + cast("Mapping[str, object]", execution) + if isinstance(execution, Mapping) + else {}, + ) + ) + chain.append((float(outcome.z), outcome.execution)) + # The latest record that holds a monitor; executions after it ran unwatched, + # each from where the one before it stopped to where it stopped itself, which is + # where the next resumed. A hard kill before the monitor is attached + # leaves a record without one and an empty stretch, and is passed over. + found = -1 + for index in range(len(chain) - 1, -1, -1): + if isinstance(chain[index][1].get("boundary_monitor"), Mapping): + found = index + break + unwatched: list[tuple[float, float]] = [] + start = 0.0 if found < 0 else chain[found][0] + for reached, _ in chain[found + 1 :]: + if reached > start: + unwatched.append((start, reached)) + start = max(start, reached) + if found < 0: + return resumed, None, tuple(unwatched) + record = cast("Mapping[str, object]", chain[found][1]["boundary_monitor"]) + return resumed, record, tuple(unwatched) + + +@dataclasses.dataclass(frozen=True) +class _FailedObservation: + """Retain drained history and writer progress when a scheduled save fails.""" + + pending: SavedPlane | None + already_saved: bool + error: BaseException + reached: float + clock: RunClock + last_kept_field: bool + + +def _method_history( + request: RunRequest, +) -> Literal["adams", "rk", "low_storage"]: + """Return which kind of method history a run's march keeps. + + An Adams history a resume can continue; Runge-Kutta controller memory (the proposal + and the norm memory), which a resume resets; or none at all. + """ + integrator = request.integration.method + if isinstance(integrator, Multistep): + return "adams" + if isinstance(integrator, LowStorageRungeKutta): + return "low_storage" + return "rk" + + +class _MarchKeeper: + """Keep the Adams march's state beside each save, and record what was kept. + + Every persisted plane and checkpoint states whether its method history was + retained, omitted or is not applicable, and why (SEP 47 section 5.5); the run's + execution record carries the latest decision (:meth:`record`). + + - ``output``: the run's handler; only one with ``save_march`` keeps a snapshot. + - ``retention``: the decision already recorded at the position the run starts + from, carried into this execution's record until it persists a position of its + own. + """ + + def __init__( + self, + output: Output, + backend: HostStepping | SessionFacts, + policy: bool | None, + *, + method: Literal["adams", "rk", "low_storage"] = "adams", + retention: Mapping[str, object] | None = None, + ) -> None: + """Hold what keeping a snapshot needs.""" + self._save = getattr(output, "save_march", None) + self._snapshot = backend.march_snapshot + self._snapshot_nbytes = backend.march_snapshot_nbytes + self._refusal = backend.march_export_refusal + self._policy = policy + self._method = method + # Measured once against the automatic limit: kept from then on, or not. + self._over_limit = False + self._within_limit = False + self._kept_z: float | None = None + self._decision: dict[str, object] | None = ( + None if retention is None else dict(retention) + ) + + def record(self) -> dict[str, object] | None: + """Return the latest retention decision, for the execution record. + + - Returns: ``"state"`` (``"retained"``, ``"omitted"`` or ``"not_applicable"``), + ``"reason"`` and ``"z"``, m, with ``"nbytes"`` where the size was measured; + ``None`` before anything was persisted. + """ + return None if self._decision is None else dict(self._decision) + + def _decide(self, z: float, state: str, reason: str, **sizes: int) -> None: + """Record a retention decision at `z`. + + - ``z``: the persisted position, m. + - ``state``: ``"retained"``, ``"omitted"`` or ``"not_applicable"``. + - ``**sizes``: ``nbytes``, the history's size where it was measured, and + ``limit``, the automatic limit it was held to, bytes. + """ + self._decision = {"state": state, "reason": reason, "z": z, **sizes} + + def keep(self, z: float, *, required: bool = False) -> None: # noqa: PLR0911, PLR0912 - one return per decision + """Keep the march's state if it stands at `z`, the position just saved. + + - ``z``: the saved plane's or the checkpoint's position, m. + - ``required``: an immutable snapshot needs the full Adams state, so the + automatic retention-size cutoff is ignored and a history that cannot be + persisted here is refused. + """ + if required and self._kept_z == z: + return + if self._method == "low_storage": + self._decide(z, "not_applicable", "no_method_history") + return + if self._method == "rk": + # The proposal and the norm memory are not persisted (issue #596). + self._decide(z, "omitted", "unsupported_method") + return + if self._save is None: + self._decide(z, "omitted", "output_cannot_store") + if required: + raise OutputError( + "snapshot requires a backend and output with Adams history" + ) + return + if self._policy is False and not required: + self._decide(z, "omitted", "explicit_policy") + return + if self._over_limit and not required: + self._decide( + z, "omitted", "automatic_size_limit", limit=MARCH_SNAPSHOT_AUTO_BYTES + ) + return + if self._policy is None and not required and not self._within_limit: + # Before any copy: the snapshot over the limit is the state and the whole + # history, on the host. + nbytes = self._snapshot_nbytes(z) + if nbytes is None: + self._omit_unavailable(z) + return + if nbytes > MARCH_SNAPSHOT_AUTO_BYTES: + logger.info( + "the Adams march's state is not kept for a resume: %d bytes is " + "over the %d kept by default (resume_history=True keeps it)", + nbytes, + MARCH_SNAPSHOT_AUTO_BYTES, + ) + self._over_limit = True + self._decide( + z, + "omitted", + "automatic_size_limit", + nbytes=nbytes, + limit=MARCH_SNAPSHOT_AUTO_BYTES, + ) + return + self._within_limit = True + snapshot = self._snapshot() + if snapshot is None or snapshot.z != z: + self._omit_unavailable(z) + if required: + raise OutputError( + "snapshot requires Adams history at the accepted endpoint" + ) + return + try: + self._save(snapshot) + except BaseException: + # A torn write reads as no snapshot (`_write_march`): expected, and lost. + self._decide(z, "omitted", "write_failed") + raise + self._kept_z = z + self._decide( + z, + "retained", + "snapshot_required" + if required + else "explicit_policy" + if self._policy + else "automatic_policy", + nbytes=snapshot.nbytes, + ) + + def _omit_unavailable(self, z: float) -> None: + """Record history that could not be had at `z`, naming an engine's refusal. + + - ``z``: the persisted position, m. + """ + refused = self._refusal() is not None + self._decide( + z, + "omitted", + "incompatible_representation" if refused else "unavailable_history", + ) + + +def _record_abandoned_step( + backend: Backend | SessionFacts, + output: CheckpointOutput, + binding: TDSEBinding | None, + failure: _FailedObservation, + step: tuple[float, float], + *, + restart: Mapping[str, object] | None = None, +) -> float | None: + """Record a failure that left a low-storage step in flight, with no new checkpoint. + + The low-storage march accepts each step in place (SEP 24 section 6.2), so a step + abandoned between its start and its commit leaves a state that is neither of the + step's ends, which the backend refuses to observe, so nothing honest can be written + as a terminal checkpoint. The file falls back to the latest state it holds beyond + its last saved plane, a running checkpoint (SEP 47 section 5.5) or the checkpoint + this execution resumed from, which is kept; without one it ends at its last saved + plane: a resume continues from one that kept its field, to rounding, with + ``step_planes`` bounding what is lost, and refuses one that did not. + + - ``step``: where the abandoned step started and its length, m. + - Returns: the position of the checkpoint fallen back to, m, or ``None`` without + one. + """ + z, dz = step + fallback = output.terminal_checkpoint + if fallback is not None: + ending = ( + f"the file falls back to its latest checkpoint, z = " + f"{fallback.plane.z:.6g} m, " + + ( + "from which resume=True continues" + if fallback.landed + else "an interpolated field, from which only " + "resume='interpolated' continues" + ) + ) + else: + kept = ( + "from which resume=True continues" + if failure.last_kept_field + else "which kept no field, so this file cannot be resumed; rerun it, " + "keeping a plane's field" + ) + ending = ( + f"the file ends at its last saved plane, z = {failure.reached:.6g} m, " + f"{kept}" + ) + reason = ( + f"no terminal checkpoint is written: the low-storage step of {dz:.6g} m from " + f"z = {z:.6g} m was abandoned in flight, so the state holds neither end of " + f"it; {ending}" + ) + failure.error.add_note(reason) + logger.warning("%s", reason) + try: + with committing(deliver=False): + _record_execution( + output, + backend, + binding, + _Status( + "failed", + failure.reached if fallback is None else fallback.plane.z, + f"{type(failure.error).__name__}: {failure.error}; {reason}", + ), + clock=failure.clock, + checkpoint=fallback, + restart=restart, + ) + except Exception as record_error: + failure.error.add_note(f"the failure could not be recorded: {record_error}") + logger.exception("could not record the failure") + return None + return None if fallback is None else fallback.plane.z + + +@dataclasses.dataclass(frozen=True) +class _Status: + """What an execution record says of the run, before its accounting is added. + + Attributes + ---------- + status : {'running', 'complete', 'budget_exhausted', 'failed'} + The run's status (:class:`~solprop.output.schema.RunOutcome`). + z : float + Last accepted or saved position, m. + message : str + Explanation of a stop or failure; empty on ordinary progress. + """ + + status: Literal["running", "complete", "budget_exhausted", "failed"] + z: float + message: str = "" + + +def _record_execution( + output: CheckpointOutput | None, + backend: Backend | SessionFacts, + binding: TDSEBinding | None, + status: _Status, + *, + clock: RunClock, + checkpoint: TerminalCheckpoint | None = None, + restart: Mapping[str, object] | None = None, +) -> None: + """Snapshot call accounting without requesting any diagnostic evaluation. + + The record also names the machine (:func:`solprop._host.machine`) and what the run + has spent so far (:func:`solprop._host.usage`): written at every save plane, it + keeps the wall time and peak memory of a run that is killed between them. + + - ``status``: requested status, absolute position in m and any error message. + - ``restart``: the execution's restart record (:data:`RESTART_RECORD`); ``None`` or + an empty one records none. + """ + if output is None: + return + record = {**backend.metadata(), "host": machine(), "resources": usage(clock)} + if restart: + record[RESTART_RECORD] = dict(restart) + monitor = record.get("boundary_monitor") + if status.status == "running" and isinstance(monitor, Mapping): + # The monitor's per-plane rows go out with the closing record alone: rewritten + # at every plane, the record grew with them and the file with their square. + record["boundary_monitor"] = { + name: value for name, value in monitor.items() if name != "planes" + } + if binding is not None: + record = {**record, "tdse": dict(binding.metadata())} + calls = record.get("response_calls", {}) + logical = cast("Mapping[str, object]", calls) if isinstance(calls, Mapping) else {} + atomic = ( + record.get("atomic_calls", {}) + if binding is None + else dataclasses.asdict(binding.ledger) + ) + # Built once, so that a saved plane validates its record once (RunOutcome). + output.record_outcome( + RunOutcome( + status.status, + status.z, + status.message, + logical_calls=logical, + atomic_calls=cast("Mapping[str, object]", atomic) + if isinstance(atomic, Mapping) + else {}, + execution=record, + ), + checkpoint, + ) diff --git a/src/solprop/driver/settings.py b/src/solprop/driver/settings.py index 55675373..f14520b3 100644 --- a/src/solprop/driver/settings.py +++ b/src/solprop/driver/settings.py @@ -274,9 +274,9 @@ class SimulationSettings(Displayed): terminal checkpoint, so that a resume continues the history -- its samples, its order and its proposed step -- as the uninterrupted run would, instead of starting it afresh at order one. ``None``, the default, keeps it while it is at - most :data:`solprop.driver.run.MARCH_SNAPSHOT_AUTO_BYTES`; it is outside the - resume key. Every backend's fused and exponential Adams engines, the native - backend's resident one included, export and install their history. + most :data:`solprop.driver.session_loop.MARCH_SNAPSHOT_AUTO_BYTES`; it is + outside the resume key. Every backend's fused and exponential Adams engines, the + native backend's resident one included, export and install their history. fields : {'all', 'planes', 'none'} Which saved planes keep their field (SEP 22): every one, the default; those of `field_planes`; or none, for a run wanted as statistics alone. A plane diff --git a/tests/driver/snapshot_worker.py b/tests/driver/snapshot_worker.py index a32a00e6..b7aee098 100644 --- a/tests/driver/snapshot_worker.py +++ b/tests/driver/snapshot_worker.py @@ -14,7 +14,7 @@ from tests.driver.test_snapshots import Requests, _run, _simulation from tests.hacc_cases import alpha_law, install, plain -from solprop.driver import run as run_module +from solprop.driver import session_loop as session_loop_module from solprop.exceptions import RunTerminated from solprop.output import ( HDF5Output, @@ -32,7 +32,7 @@ def install_signal_phase(patch, phase, signal_once): if phase == "checkpoint": save_march = HDF5Output.save_march # Force history persistence into the snapshot transaction itself. - patch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) + patch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) def write(self, snapshot): signal_once() diff --git a/tests/driver/test_restart_record.py b/tests/driver/test_restart_record.py index 4f1fd7bd..2a6342bf 100644 --- a/tests/driver/test_restart_record.py +++ b/tests/driver/test_restart_record.py @@ -21,6 +21,7 @@ import pytest import solprop.driver.run as run_module +import solprop.driver.session_loop as session_loop_module from driver.test_abandoned_step import built as low_storage from driver.test_resume import run_until from driver.test_resume_history import AFRESH, soliton @@ -130,7 +131,7 @@ def test_a_kept_history_set_aside_on_resume_is_recorded_without_a_warning( def test_a_history_over_the_automatic_limit_warns_once_on_resume( build_simulation, tmp_path, monkeypatch ): - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) build = soliton(build_simulation, "numpy_ref") path = tmp_path / "run.h5" z = interrupted(build, path) @@ -269,7 +270,7 @@ def test_a_resumed_monitor_states_its_reconstruction(build_simulation, tmp_path) def test_a_required_snapshot_records_its_retention(tmp_path, monkeypatch): from driver.test_snapshots import ordinary # noqa: PLC0415 - its case - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) controller = SnapshotController(tmp_path / "snapshots") controller.request() ordinary(integrator=Multistep()).run( diff --git a/tests/driver/test_resume_history.py b/tests/driver/test_resume_history.py index a5428b8c..cf5472e3 100644 --- a/tests/driver/test_resume_history.py +++ b/tests/driver/test_resume_history.py @@ -26,7 +26,7 @@ import numpy as np import pytest -import solprop.driver.run as run_module +import solprop.driver.session_loop as session_loop_module from backends.test_exponential_varying import EXPONENTIAL, gradient from driver.test_resume import LENGTH, N_SAVE, CrashError, run_until from flow_parity import LENGTH as FLOW_LENGTH @@ -244,7 +244,7 @@ def test_without_the_history_a_resume_starts_afresh(build_simulation, tmp_path): def test_a_snapshot_over_the_default_size_is_kept_only_on_request( build_simulation, tmp_path, monkeypatch, caplog ): - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) path = tmp_path / "run.h5" with caplog.at_level(logging.INFO, logger="solprop.driver.run"): soliton(build_simulation, "numpy_ref")().run(path, progress="none") @@ -264,7 +264,7 @@ def test_a_snapshot_over_the_default_size_is_never_copied( # The state and the whole history on the host, to learn a size that needs no copy. if backend not in available(): pytest.skip(f"{backend} is not installed") - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) def refuse(*_): pytest.fail("a snapshot over the limit was taken") diff --git a/tests/driver/test_run.py b/tests/driver/test_run.py index 642adfc5..3dec10fc 100644 --- a/tests/driver/test_run.py +++ b/tests/driver/test_run.py @@ -32,7 +32,7 @@ from solprop.backends.registry import select from solprop.backends.session import Start from solprop.driver import Simulation, SimulationSettings, metadata_for, run_problem -from solprop.driver.run import _history_of, plane_from_observation +from solprop.driver.run import history_of, plane_from_observation from solprop.exceptions import BackendError, DriverError from solprop.grid import EnvelopeGrid from solprop.log import NullReporter @@ -735,7 +735,7 @@ def finished(self, total): ) -def test_history_of_stores_start_positions_and_per_attempt_times(): +def testhistory_of_stores_start_positions_and_per_attempt_times(): history = BackendHistory( np.array([0.5, 1.0]), np.array([0.5, 0.5]), @@ -743,6 +743,6 @@ def test_history_of_stores_start_positions_and_per_attempt_times(): np.array([True, True]), np.array([0.25, 0.75]), ) - stored = _history_of(history) + stored = history_of(history) assert stored.z.tolist() == [0.0, 0.5] assert stored.wall_time.tolist() == [0.25, 0.5] diff --git a/tests/driver/test_snapshots.py b/tests/driver/test_snapshots.py index 8bf27a38..27e28479 100644 --- a/tests/driver/test_snapshots.py +++ b/tests/driver/test_snapshots.py @@ -20,8 +20,8 @@ from tests.driver.test_tdse_natural_output import _simulation, assert_no_extra_call from tests.hacc_cases import alpha_law, install, plain -import solprop.driver.run as run_module import solprop.driver.runner as runner_module +import solprop.driver.session_loop as session_loop_module import solprop.driver.simulation as simulation_module from march_bounds import TIGHTER, assert_in_envelope from solprop.backends.registry import available @@ -266,7 +266,7 @@ def test_ordinary_snapshot_resumes_in_a_fresh_process(backend, tmp_path): def test_snapshot_requires_history_even_above_the_automatic_limit( tmp_path, monkeypatch ): - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) controller = SnapshotController(tmp_path / "snapshots") controller.request() ordinary().run(tmp_path / "working.h5", snapshots=controller, progress="none") diff --git a/tests/output/test_callback.py b/tests/output/test_callback.py index b9705f3e..4f26ab28 100644 --- a/tests/output/test_callback.py +++ b/tests/output/test_callback.py @@ -32,7 +32,7 @@ from solprop.backends.registry import available from solprop.backends.session import Start from solprop.driver import Simulation -from solprop.driver import service as service_module +from solprop.driver import session_loop as service_module from solprop.driver.run import run_problem from solprop.exceptions import DriverError, OutputError from solprop.output import ( diff --git a/tests/output/test_snapshot_failures.py b/tests/output/test_snapshot_failures.py index f215c815..fb8cea89 100644 --- a/tests/output/test_snapshot_failures.py +++ b/tests/output/test_snapshot_failures.py @@ -324,7 +324,9 @@ def flush_failure(): def test_missing_backend_history_stops_clearly(working): - from solprop.driver.run import _MarchKeeper # noqa: PLC0415 - failure seam only + from solprop.driver.session_loop import ( # noqa: PLC0415 - failure seam only + _MarchKeeper, + ) keeper = object.__new__(_MarchKeeper) keeper._kept_z = None diff --git a/tests/sessions/test_destination_export.py b/tests/sessions/test_destination_export.py index 325b3d1a..e4f7c82e 100644 --- a/tests/sessions/test_destination_export.py +++ b/tests/sessions/test_destination_export.py @@ -396,7 +396,7 @@ def _served( ) -> Any: """Serve a native session into `output`; return the session's status.""" from solprop.driver.run import field_retention # noqa: PLC0415 - from solprop.driver.service import ServiceContext, serve # noqa: PLC0415 + from solprop.driver.session_loop import ServiceContext, serve # noqa: PLC0415 from solprop.log import resolve_reporter # noqa: PLC0415 request = run_request.for_run(stats=StatsSpec()) diff --git a/tests/sessions/test_native_session_monitor.py b/tests/sessions/test_native_session_monitor.py index f7a07ce9..da0203b5 100644 --- a/tests/sessions/test_native_session_monitor.py +++ b/tests/sessions/test_native_session_monitor.py @@ -490,7 +490,7 @@ def _through_serve(simulation: Simulation, kind: str, *, error: bool = False) -> """ from solprop.driver.metadata import metadata_for # noqa: PLC0415 from solprop.driver.run import field_retention # noqa: PLC0415 - from solprop.driver.service import ServiceContext, serve # noqa: PLC0415 + from solprop.driver.session_loop import ServiceContext, serve # noqa: PLC0415 from solprop.log import resolve_reporter # noqa: PLC0415 from solprop.output import MemoryOutput # noqa: PLC0415 diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 5c481400..6c5ae252 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -31,8 +31,8 @@ from solprop.constants import C from solprop.driver import Simulation, SimulationSettings, _scan_batch, prop_gnlse from solprop.driver.executors import Batch, Local -from solprop.driver.run import _earlier_execution from solprop.driver.scan import Scan, ScanPoint, run_scan +from solprop.driver.session_loop import _earlier_execution from solprop.exceptions import BackendError, BoundaryError, BoundaryWarning, OutputError from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid from solprop.log import NullReporter From ccaf283356e2bc69f3a90c374cc101c9ea65e239 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:31:40 +0000 Subject: [PATCH 31/42] Record the session loop's rename in the changelog Co-Authored-By: Claude Opus 5.5 --- changelog.d/driver-session-loop.breaking.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/driver-session-loop.breaking.md diff --git a/changelog.d/driver-session-loop.breaking.md b/changelog.d/driver-session-loop.breaking.md new file mode 100644 index 00000000..3045b809 --- /dev/null +++ b/changelog.d/driver-session-loop.breaking.md @@ -0,0 +1,4 @@ +`solprop.driver.service`, the loop that services a run's execution session, is renamed +`solprop.driver.session_loop`, apart from the front end's `solprop.service`; its +`MARCH_SNAPSHOT_AUTO_BYTES` limit, formerly `solprop.driver.run.MARCH_SNAPSHOT_AUTO_BYTES`, +moved with it. From c4d6edffd7e330fe2af41aaea3b05f860abd7a0a Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 06:34:41 +0000 Subject: [PATCH 32/42] Regenerate the gallery pages of the scripts that now call from_free The six radial tutorials and lessons call Simulation.from_free and prop_free; their committed outputs are rebuilt (SOLPROP_RUN_EXAMPLES=stale). Their numbers match the parent commit's code run the same way: the differences from the previous pages predate this branch. Co-Authored-By: Claude Opus 5.5 --- ..._plot_08_radial_capillary_coupling_005.png | Bin 53654 -> 53637 bytes docs/auto_tutorials/free_space/index.rst | 4 ++-- .../free_space/plot_01_radial.codeobj.json | 20 +++++++++--------- .../free_space/plot_01_radial.ipynb | 4 ++-- .../free_space/plot_01_radial.py | 4 ++-- .../free_space/plot_01_radial.py.md5 | 2 +- .../free_space/plot_01_radial.rst | 6 +++--- .../free_space/plot_01_radial.zip | Bin 14700 -> 14692 bytes ..._08_radial_capillary_coupling.codeobj.json | 18 ++++++++-------- .../plot_08_radial_capillary_coupling.ipynb | 2 +- .../plot_08_radial_capillary_coupling.py | 2 +- .../plot_08_radial_capillary_coupling.py.md5 | 2 +- .../plot_08_radial_capillary_coupling.rst | 6 +++--- .../plot_08_radial_capillary_coupling.zip | Bin 48219 -> 48215 bytes .../plot_09_transverse_window.codeobj.json | 16 +++++++------- .../plot_09_transverse_window.ipynb | 2 +- .../free_space/plot_09_transverse_window.py | 2 +- .../plot_09_transverse_window.py.md5 | 2 +- .../free_space/plot_09_transverse_window.rst | 18 ++++++++-------- .../free_space/plot_09_transverse_window.zip | Bin 21867 -> 21863 bytes .../plot_10_radial_kerr.codeobj.json | 12 +++++------ .../free_space/plot_10_radial_kerr.ipynb | 6 +++--- .../free_space/plot_10_radial_kerr.py | 6 +++--- .../free_space/plot_10_radial_kerr.py.md5 | 2 +- .../free_space/plot_10_radial_kerr.rst | 10 ++++----- .../free_space/plot_10_radial_kerr.zip | Bin 7926 -> 7914 bytes .../plot_11_radial_filament.codeobj.json | 12 +++++------ .../free_space/plot_11_radial_filament.ipynb | 6 +++--- .../free_space/plot_11_radial_filament.py | 6 +++--- .../free_space/plot_11_radial_filament.py.md5 | 2 +- .../free_space/plot_11_radial_filament.rst | 10 ++++----- .../free_space/plot_11_radial_filament.zip | Bin 11307 -> 11295 bytes docs/auto_tutorials/index.rst | 4 ++-- .../lessons/plot_03_filament.codeobj.json | 18 ++++++++-------- .../lessons/plot_03_filament.ipynb | 2 +- docs/introduction/lessons/plot_03_filament.py | 2 +- .../lessons/plot_03_filament.py.md5 | 2 +- .../introduction/lessons/plot_03_filament.rst | 6 +++--- .../introduction/lessons/plot_03_filament.zip | Bin 20039 -> 20035 bytes 39 files changed, 108 insertions(+), 108 deletions(-) diff --git a/docs/auto_tutorials/free_space/images/sphx_glr_plot_08_radial_capillary_coupling_005.png b/docs/auto_tutorials/free_space/images/sphx_glr_plot_08_radial_capillary_coupling_005.png index 22c007be7530dc17bf7285d5985fd292900624f2..452dca4f32bba6cdee1cd01ed4842669b2aaf96f 100644 GIT binary patch literal 53637 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

1$TAG`4qhNj$ACY4NW=hG^u30>Hq_4lHVR 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

  • bI_m?ipcol2QE1!QU! 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[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 Ldqt-RYxCeg#rh_p literal 64620 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 Phkpk(^!7hgv%UB~wLbm~ diff --git a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_004.png b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_004.png index 1a8bc80a40b18410cd0fc3e367f7e20245d628af..a984c26f388c5f00098d018079759c25aea775ae 100644 GIT binary patch literal 24713 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 MZ@QV0XY3U6A2!($WdHyG diff --git a/docs/auto_tutorials/free_space/images/thumb/sphx_glr_plot_07_far_field_statistics_thumb.png b/docs/auto_tutorials/free_space/images/thumb/sphx_glr_plot_07_far_field_statistics_thumb.png index 417555997abe3e53fcff6b4208fd1f1974d1138a..abb1ca0d71d5c1af952ad9015dc184e8e6a004d6 100644 GIT binary patch literal 34108 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 IxS{|50uT$Bk^lez literal 36668 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 diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.codeobj.json b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.codeobj.json index 49086535..1ad9d534 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.codeobj.json +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.codeobj.json @@ -408,7 +408,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "SavedPlane" }, @@ -443,7 +443,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "SavedPlane" } @@ -452,7 +452,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -2461,7 +2461,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.store", "module_short": "solprop.output", "name": "PlaneStore.plane_field" }, @@ -2704,7 +2704,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.ipynb b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.ipynb index 45ccab99..7594601d 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.ipynb +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.ipynb @@ -22,7 +22,7 @@ }, "outputs": [], "source": [ - "import time\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Circle\nfrom scipy import integrate\n\nfrom solprop.boundaries import SpatialAbsorber, absorber, custom_absorber\nfrom solprop.constants import C\nfrom solprop.driver import SimulationSettings, prop_free\nfrom solprop.grid import EnvelopeGrid, FreeGrid\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.output.schema import SavedPlane\nfrom solprop.physdata.nonlinear_data import chi3_from_n2\nfrom solprop.pulses import GaussianPulse\nfrom solprop.spatial import (\n SpatialFlux,\n SpatialMedium,\n aperture_profile,\n quadrature_half_width,\n spatial_energy,\n)\nfrom solprop.stats import ApertureSet, StatsSpec, collect\nfrom solprop.stepper import FixedSchedule, PIController\n\nstarted = time.perf_counter()\nLENGTH = 1e-3\nWAVELENGTH = 800e-9\nINDEX = 1.45\nN2 = 3e-20\nWAIST = 40e-6\nFIELD = 7.5051e8 # V/m, the peak of each beam's analytic field\nFWHM = 50e-15\nANGLE = 0.010\nK0 = 2 * np.pi * INDEX / WAVELENGTH * np.sin(ANGLE)\ngrid = EnvelopeGrid.from_limits(LENGTH, WAVELENGTH, (740e-9, 870e-9), 300e-15)\nspace = FreeGrid(0.4e-3, 224, 0.4e-3, 64, collar_fraction=0.0)\nk_max = float(np.max(np.abs(space.k_x)))\nprint(f\"K0/k_max = {K0 / k_max:.2f}; grid {space.n_x} x {space.n_y} x {grid.n_omega}\")" + "import time\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Circle\nfrom scipy import integrate\n\nfrom solprop.boundaries import SpatialAbsorber, absorber, custom_absorber\nfrom solprop.constants import C\nfrom solprop.driver import SimulationSettings, prop_free\nfrom solprop.grid import EnvelopeGrid, FreeGrid\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.output.records import SavedPlane\nfrom solprop.physdata.nonlinear_data import chi3_from_n2\nfrom solprop.pulses import GaussianPulse\nfrom solprop.spatial import (\n SpatialFlux,\n SpatialMedium,\n aperture_profile,\n quadrature_half_width,\n spatial_energy,\n)\nfrom solprop.stats import ApertureSet, StatsSpec, collect\nfrom solprop.stepper import FixedSchedule, PIController\n\nstarted = time.perf_counter()\nLENGTH = 1e-3\nWAVELENGTH = 800e-9\nINDEX = 1.45\nN2 = 3e-20\nWAIST = 40e-6\nFIELD = 7.5051e8 # V/m, the peak of each beam's analytic field\nFWHM = 50e-15\nANGLE = 0.010\nK0 = 2 * np.pi * INDEX / WAVELENGTH * np.sin(ANGLE)\ngrid = EnvelopeGrid.from_limits(LENGTH, WAVELENGTH, (740e-9, 870e-9), 300e-15)\nspace = FreeGrid(0.4e-3, 224, 0.4e-3, 64, collar_fraction=0.0)\nk_max = float(np.max(np.abs(space.k_x)))\nprint(f\"K0/k_max = {K0 / k_max:.2f}; grid {space.n_x} x {space.n_y} x {grid.n_omega}\")" ] }, { @@ -211,7 +211,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.7" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py index 7990fdd4..2b7225d6 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py @@ -58,7 +58,7 @@ from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, FreeGrid from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.physdata.nonlinear_data import chi3_from_n2 from solprop.pulses import GaussianPulse from solprop.spatial import ( diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py.md5 b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py.md5 index ccfe2ec6..4a5e354c 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py.md5 +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py.md5 @@ -1 +1 @@ -9383f10016d888e542e1a82d7833ddf0 \ No newline at end of file +64d54e8f5b9f1c1312a5d2b6af83def0 \ No newline at end of file diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.rst b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.rst index d48d6496..5fa92996 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.rst +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.rst @@ -78,7 +78,7 @@ of 0.8 mm, twenty waists, puts six lattice points across the far-field radius. from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, FreeGrid from solprop.nonlinear.responses import KerrResponse - from solprop.output.schema import SavedPlane + from solprop.output.records import SavedPlane from solprop.physdata.nonlinear_data import chi3_from_n2 from solprop.pulses import GaussianPulse from solprop.spatial import ( @@ -372,7 +372,7 @@ statistic here weights it. .. code-block:: none - + @@ -423,7 +423,7 @@ peaks near the coherence length :math:`\pi/|\Delta k|` and falls back. coherence length pi/|dk| = 0.69 mm signal peaks at z = 0.750 mm - + @@ -639,8 +639,8 @@ to the beam spectrum needs no further normalisation. .. code-block:: none - collect against the run: 2.5e-16 - from the definition against the run, largest difference in collected fraction: 2.8e-16 + collect against the run: 6.2e-16 + from the definition against the run, largest difference in collected fraction: 2.2e-16 @@ -694,7 +694,7 @@ transmission of the exit face into air is not modelled. quadrants against the beam spectrum: 3.2e-16 towards +x 1.3721e-07 J, towards -x 1.3708e-07 J; upper 1.5409e-07 J, lower 1.2020e-07 J - exit energy 2.742885e-07 J; beam spectrum integral 2.742885e-07 J; relative difference 6.0e-15 + exit energy 2.742885e-07 J; beam spectrum integral 2.742885e-07 J; relative difference 3.9e-15 @@ -752,7 +752,7 @@ the evanescent, material and custom channels are exactly zero. energy drop 1.5701e-08 J; absorbed 1.5704e-08 J evanescent, material and custom channels: 0.0 J/m; transverse at the exit 7.6e-10 J/m - tutorial run time: 21.9 s + tutorial run time: 2.1 s @@ -770,7 +770,7 @@ far-zone and Fourier-plane conditions and the paraxial error; .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 22.151 seconds) + **Total running time of the script:** (0 minutes 2.134 seconds) .. _sphx_glr_download_auto_tutorials_free_space_plot_07_far_field_statistics.py: diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.zip b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.zip index fa491d00ef14eed07e2b8d55a591eec7c9c4412f..646618a5072d94d7edfb557a73b3e96646e68e44 100644 GIT binary patch delta 178 zcmZ4A#k{eLSvX;A90jNF$-v(JlF;Zv80h Date: Thu, 12 Nov 2026 07:05:02 +0000 Subject: [PATCH 36/42] Skip prop_* docstring composition when docstrings are stripped Under python -OO every __doc__ is None and _propagates raised on import; it now leaves the docstring None. A test imports the interface with -OO. Co-Authored-By: Claude Opus 5.5 --- src/solprop/driver/interface.py | 7 +++++-- tests/driver/test_route_keywords.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/solprop/driver/interface.py b/src/solprop/driver/interface.py index 8324f0f0..7a58dfd5 100644 --- a/src/solprop/driver/interface.py +++ b/src/solprop/driver/interface.py @@ -153,11 +153,14 @@ def _propagates[**P]( def compose(run: Callable[P, Output]) -> Callable[P, Output]: """Write the composed docstring onto `run`.""" - own = inspect.cleandoc(run.__doc__ or "") + if run.__doc__ is None or build.__doc__ is None: + # Docstrings stripped (``python -OO``): there is nothing to compose. + return run + own = inspect.cleandoc(run.__doc__) names = list(inspect.signature(run).parameters) block = ( "Parameters\n----------\n" - + _parameters(inspect.cleandoc(build.__doc__ or "")) + + _parameters(inspect.cleandoc(build.__doc__)) + _run_controls(names) + "\n" ) diff --git a/tests/driver/test_route_keywords.py b/tests/driver/test_route_keywords.py index d86171a9..8a606bd5 100644 --- a/tests/driver/test_route_keywords.py +++ b/tests/driver/test_route_keywords.py @@ -13,6 +13,8 @@ import annotationlib import inspect +import subprocess +import sys import numpy as np import pytest @@ -165,6 +167,17 @@ def test_each_interface_s_composed_docstring_is_valid(interface, constructor, co assert documented == [*inspect.signature(constructor).parameters, *controls] +def test_the_interface_imports_with_its_docstrings_stripped(): + # Under -OO every __doc__ is None: the composition is skipped, not an error. + result = subprocess.run( + [sys.executable, "-OO", "-c", "import solprop.driver.interface"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + # ------------------------------------------------------------------ the planes From 9506a2a05db2b2c946718cc4aec3788f221b75e0 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 07:05:07 +0000 Subject: [PATCH 37/42] Call from_free in the gemm-shapes benchmark Co-Authored-By: Claude Opus 5.5 --- native/benchmarks/gemm_shapes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/benchmarks/gemm_shapes.py b/native/benchmarks/gemm_shapes.py index aaae91a0..83d00623 100644 --- a/native/benchmarks/gemm_shapes.py +++ b/native/benchmarks/gemm_shapes.py @@ -81,7 +81,7 @@ def radial(nodes: int, trange: float) -> Simulation: pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), 100e-6 ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1e-3, nodes), SpatialMedium("Ar", 1.0), From acc9cc35c7183d5cbf6b787830f7c91e43444370 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 07:06:24 +0000 Subject: [PATCH 38/42] Write NumPy scalars as their values in canonical identities canonical_json no longer stringifies unknown values, so a record holding np.bool_ (a comparison of arrays) or an np.integer raised TypeError. Its default= now writes a NumPy bool, integer or floating scalar as the Python value it holds and still refuses anything else; the stored golden digests are unchanged. A material manifest's entries take the same rule. Co-Authored-By: Claude Opus 5.5 --- src/solprop/_identity.py | 34 +++++++++++++++++------- src/solprop/physdata/selection.py | 11 +++++++- tests/physdata/test_material_manifest.py | 7 +++++ tests/test_identity.py | 9 +++++++ 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/solprop/_identity.py b/src/solprop/_identity.py index 0f3c38c6..3c1939e3 100644 --- a/src/solprop/_identity.py +++ b/src/solprop/_identity.py @@ -8,19 +8,23 @@ coupling plane's basis, a material population, a TDSE basis or descriptor, a cache key -- is taken over :func:`canonical_json`, so that two records equal as JSON have one identity on every platform and in every process. The text has its keys sorted and no -whitespace. A value JSON cannot hold raises :class:`TypeError`: an identity that -stringified an object would silently merge records that differ (an array's ``str`` is -abbreviated). Non-finite numbers are written as JSON's ``NaN`` and ``Infinity``, which -spell them deterministically (a material's open interval bound is infinite), unless the -caller asks for finite values only. - -Like ``_display``, this module imports nothing from the rest of the package, so any -layer may use it. +whitespace. A NumPy scalar (a bool, an integer or a floating number, which records built +from arrays carry) is written as the Python number it holds; any other value JSON cannot +hold raises :class:`TypeError`: an identity that stringified an object would silently +merge records that differ (an array's ``str`` is abbreviated). Non-finite numbers are +written as JSON's ``NaN`` and ``Infinity``, which spell them deterministically (a +material's open interval bound is infinite), unless the caller asks for finite values +only. + +Like ``_display``, this module imports nothing from the rest of the package (NumPy +alone), so any layer may use it. """ import hashlib import json +import numpy as np + __all__ = ["canonical_digest", "canonical_json", "text_digest"] @@ -31,10 +35,22 @@ def canonical_json(value: object, *, finite: bool = False) -> str: whose every number is a measured or configured finite quantity. """ return json.dumps( - value, sort_keys=True, separators=(",", ":"), allow_nan=not finite + value, + sort_keys=True, + separators=(",", ":"), + allow_nan=not finite, + default=_scalar, ) +def _scalar(value: object) -> object: + """Return a NumPy scalar as the Python value it holds; refuse anything else.""" + if isinstance(value, np.bool_ | np.integer | np.floating): + return value.item() + msg = f"Object of type {type(value).__name__} is not JSON serializable" + raise TypeError(msg) + + def text_digest(text: str) -> str: """Return the SHA-256 of a text's UTF-8 bytes: 64 lowercase hex digits.""" return hashlib.sha256(text.encode("utf-8")).hexdigest() diff --git a/src/solprop/physdata/selection.py b/src/solprop/physdata/selection.py index 16e8e672..a8b93bd8 100644 --- a/src/solprop/physdata/selection.py +++ b/src/solprop/physdata/selection.py @@ -31,6 +31,8 @@ from dataclasses import dataclass, field from typing import Final, Protocol, cast, runtime_checkable +import numpy as np + from solprop._identity import canonical_json from solprop.exceptions import MaterialError @@ -534,11 +536,18 @@ def to_dict(self) -> Mapping[str, object]: def _plain(value: object) -> object: - """Return a value with tuples as lists and mappings as dicts, recursively.""" + """Return a value with tuples as lists and mappings as dicts, recursively. + + A NumPy scalar becomes the Python value it holds, as in its canonical JSON + (:func:`solprop._identity.canonical_json`), so an entry compares and stores as it + is identified. + """ if isinstance(value, Mapping): return {str(key): _plain(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_plain(item) for item in value] + if isinstance(value, np.bool_ | np.integer | np.floating): + return value.item() return value diff --git a/tests/physdata/test_material_manifest.py b/tests/physdata/test_material_manifest.py index e806bdec..0fd31ef1 100644 --- a/tests/physdata/test_material_manifest.py +++ b/tests/physdata/test_material_manifest.py @@ -91,6 +91,13 @@ def test_entries_are_deduplicated_and_sorted_whatever_their_order(): assert left.species == right.species == ({"label": "a"}, {"label": "b"}) +def test_an_entry_s_numpy_scalars_are_kept_as_python_values(): + entry = {"label": "Ar", "scaled": np.float64(0.0) == 0.0, "index": np.int64(2)} + (kept,) = manifest(species=(entry,)).species + assert kept == {"label": "Ar", "scaled": True, "index": 2} + assert type(kept["scaled"]) is bool and type(kept["index"]) is int + + def test_records_sharing_a_name_are_keyed_apart_by_number(): # A curated model and its override share their material, kind and name. keys = sorted(manifest().hashed()["records"]) diff --git a/tests/test_identity.py b/tests/test_identity.py index 5a51047e..22400625 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -47,6 +47,15 @@ def test_an_identity_refuses_a_value_json_cannot_hold(): canonical_digest({"values": np.zeros(3)}) +def test_a_numpy_scalar_is_written_as_the_number_it_holds(): + # A comparison of arrays gives np.bool_, an index np.int64: records carry both. + record = {"zero": np.float64(0.0) == 0.0, "count": np.int64(3)} + assert canonical_json(record) == '{"count":3,"zero":true}' + assert canonical_json({"half": np.float32(0.5)}) == '{"half":0.5}' + with pytest.raises(TypeError, match="not JSON serializable"): + canonical_json({"value": np.complex128(1.0)}) + + @pytest.mark.parametrize( ("name", "digest"), [ From 82c177cbebefa83b510b00a3f278241e4a9d3ac7 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 07:07:47 +0000 Subject: [PATCH 39/42] Keep one MARCH_SNAPSHOT_AUTO_BYTES, in translate.run_request The session loop defined its own copy; it now imports the request's, and the tests keep patching the name where the loop reads it. Co-Authored-By: Claude Opus 5.5 --- changelog.d/driver-session-loop.breaking.md | 6 +++--- src/solprop/driver/run.py | 2 +- src/solprop/driver/session_loop.py | 11 +---------- src/solprop/driver/settings.py | 2 +- src/solprop/translate/run_request.py | 7 ++++++- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/changelog.d/driver-session-loop.breaking.md b/changelog.d/driver-session-loop.breaking.md index 3045b809..d044bc8b 100644 --- a/changelog.d/driver-session-loop.breaking.md +++ b/changelog.d/driver-session-loop.breaking.md @@ -1,4 +1,4 @@ `solprop.driver.service`, the loop that services a run's execution session, is renamed -`solprop.driver.session_loop`, apart from the front end's `solprop.service`; its -`MARCH_SNAPSHOT_AUTO_BYTES` limit, formerly `solprop.driver.run.MARCH_SNAPSHOT_AUTO_BYTES`, -moved with it. +`solprop.driver.session_loop`, apart from the front end's `solprop.service`. +`solprop.driver.run.MARCH_SNAPSHOT_AUTO_BYTES` is gone; the one limit is +`solprop.translate.run_request.MARCH_SNAPSHOT_AUTO_BYTES`. diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index 515ac8bb..693bfa45 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -372,7 +372,7 @@ def run_problem( Whether an output that can keep it (``save_march``) keeps the Adams march's state at every save plane and terminal checkpoint, so that a resume continues the history instead of starting it afresh. ``None`` keeps it while it is at - most :data:`~solprop.driver.session_loop.MARCH_SNAPSHOT_AUTO_BYTES`. + most :data:`~solprop.translate.run_request.MARCH_SNAPSHOT_AUTO_BYTES`. fields : {'all', 'planes', 'none'}, optional Which saved planes keep their field (SEP 22): every one (the default), those of `field_planes`, or none. A plane that keeps none stores its position, its diff --git a/src/solprop/driver/session_loop.py b/src/solprop/driver/session_loop.py index 3d1284a9..b912c138 100644 --- a/src/solprop/driver/session_loop.py +++ b/src/solprop/driver/session_loop.py @@ -100,11 +100,10 @@ from solprop.stepper.multistep import Multistep from solprop.translate.problem import PropagationProblem from solprop.translate.routes import transverse_of -from solprop.translate.run_request import RunRequest +from solprop.translate.run_request import MARCH_SNAPSHOT_AUTO_BYTES, RunRequest from solprop.translate.tdse import TDSEBinding __all__ = [ - "MARCH_SNAPSHOT_AUTO_BYTES", "MONITOR_RECONSTRUCTED", "ServiceContext", "serve", @@ -112,14 +111,6 @@ logger = logging.getLogger("solprop.driver.run") -MARCH_SNAPSHOT_AUTO_BYTES = 256 * 2**20 -"""The largest Adams march snapshot a run keeps when ``resume_history`` is ``None``. - -The snapshot -- the state and the history's rows, a few to sixty copies of the state -- -is rewritten at every save plane; a state too large for that to be cheap keeps none -unless asked to, and its resume starts the history afresh. -""" - _CONSUMER = ExportRecipe(power=True, optics=False) """The field a plane observer, a checkpoint record and an output that is not a plane store hold: output amplitudes in the internal FFT convention (SEP 47 section 5.4).""" diff --git a/src/solprop/driver/settings.py b/src/solprop/driver/settings.py index 95d8956e..0598fb7c 100644 --- a/src/solprop/driver/settings.py +++ b/src/solprop/driver/settings.py @@ -272,7 +272,7 @@ class SimulationSettings(Displayed): terminal checkpoint, so that a resume continues the history -- its samples, its order and its proposed step -- as the uninterrupted run would, instead of starting it afresh at order one. ``None``, the default, keeps it while it is at - most :data:`solprop.driver.session_loop.MARCH_SNAPSHOT_AUTO_BYTES`; it is + most :data:`solprop.translate.run_request.MARCH_SNAPSHOT_AUTO_BYTES`; it is outside the resume key. Every backend's fused and exponential Adams engines, the native backend's resident one included, export and install their history. fields : {'all', 'planes', 'none'} diff --git a/src/solprop/translate/run_request.py b/src/solprop/translate/run_request.py index 6c2e2a8a..8506f793 100644 --- a/src/solprop/translate/run_request.py +++ b/src/solprop/translate/run_request.py @@ -69,7 +69,12 @@ ] MARCH_SNAPSHOT_AUTO_BYTES = 256 * 2**20 -"""The automatic method-history retention limit, bytes (SEP 47 section 5.5).""" +"""The largest Adams march snapshot a run keeps with ``resume_history=None``, bytes. + +The snapshot -- the state and the history's rows, a few to sixty copies of the state -- +is rewritten at every save plane; a state too large for that to be cheap keeps none +unless asked to, and its resume starts the history afresh (SEP 47 section 5.5). +""" class MethodKind(enum.Enum): From 6a51389bf8516066b96ff3b700a4c0315153efb7 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 07:08:20 +0000 Subject: [PATCH 40/42] Call open_run where open_run_record wrapped it The wrapper only discarded open_run's return; the refused-run case it documented is now described on open_run. Co-Authored-By: Claude Opus 5.5 --- changelog.d/open-run.removed.md | 2 ++ src/solprop/driver/run.py | 33 +++++------------------------- src/solprop/driver/tdse_run.py | 4 ++-- tests/driver/test_tdse_hacc_run.py | 2 +- 4 files changed, 10 insertions(+), 31 deletions(-) create mode 100644 changelog.d/open-run.removed.md diff --git a/changelog.d/open-run.removed.md b/changelog.d/open-run.removed.md new file mode 100644 index 00000000..4dce7852 --- /dev/null +++ b/changelog.d/open-run.removed.md @@ -0,0 +1,2 @@ +`solprop.driver.run.open_run_record` is removed in favour of `solprop.driver.run.open_run`, +which opens an output with the same record and also returns its checkpoint interface. diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index 693bfa45..850e6306 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -98,7 +98,6 @@ "field_retention", "history_of", "open_run", - "open_run_record", "plane_from_observation", "retention_at", "run_problem", @@ -702,6 +701,11 @@ def open_run( ) -> CheckpointOutput | None: """Open a run's output with the runtime facts outside its scientific identity. + A run refused after its resources were prepared and before its first step (a + failed haCC consistency check, SEP 20 section 5.4) is recorded by this alone: the + record, with the binding's provenance, and no plane or terminal checkpoint, so + it cannot be mistaken for a propagation state. + Parameters ---------- output : solprop.output.store.Output @@ -733,33 +737,6 @@ def open_run( return output if isinstance(output, CheckpointOutput) else None -def open_run_record( - output: Output, - metadata: RunMetadata, - backend: Backend | SessionFacts, - binding: TDSEBinding | None, -) -> None: - """Open an output with the run's record and execution facts, and save nothing. - - Used when a run is refused after its resources were prepared and before its - first step, such as a failed haCC consistency check (SEP 20 section 5.4): the - record, with the binding's provenance, is written without a plane or a - terminal checkpoint, so it cannot be mistaken for a propagation state. - - Parameters - ---------- - output : Output - Handler to open. - metadata : RunMetadata - Scientific description of the run. - backend : Backend - Prepared optical backend. - binding : TDSEBinding or None - Optional atomic service supplying execution provenance. - """ - open_run(output, metadata, backend, binding) - - RESTART_RECORD = "restart" """The execution record's entry for what a restart keeps and restores (SEP 47 5.5). diff --git a/src/solprop/driver/tdse_run.py b/src/solprop/driver/tdse_run.py index 9b973670..84242ada 100644 --- a/src/solprop/driver/tdse_run.py +++ b/src/solprop/driver/tdse_run.py @@ -27,7 +27,7 @@ from solprop.boundaries import absorber as collar from solprop.boundaries import temporal_collar_rate from solprop.driver import tdse_consistency -from solprop.driver.run import open_run_record +from solprop.driver.run import open_run from solprop.driver.settings import SimulationSettings, _scheme from solprop.exceptions import DriverError, TDSEArchiveError, TDSECostWarning from solprop.grid import TimeGrid @@ -176,7 +176,7 @@ def _consistency_gate( # noqa: PLR0917 - called positionally binding.record_consistency(result.to_record()) if result.outcome in ("refused", "invalid"): try: - open_run_record(handler, metadata, backend, binding) + open_run(handler, metadata, backend, binding) except Exception: logger.exception("could not record the refused consistency check") tdse_consistency.apply_outcome(result) diff --git a/tests/driver/test_tdse_hacc_run.py b/tests/driver/test_tdse_hacc_run.py index 45c2720f..80ab4f25 100644 --- a/tests/driver/test_tdse_hacc_run.py +++ b/tests/driver/test_tdse_hacc_run.py @@ -274,7 +274,7 @@ def test_a_failed_refusal_record_keeps_the_consistency_error_primary( def broken(*args, **kwargs): raise OutputError("disk full") - monkeypatch.setattr(tdse_run_module, "open_run_record", broken) + monkeypatch.setattr(tdse_run_module, "open_run", broken) fake.response = alpha_law(0.5) with pytest.raises(TDSEConsistencyError, match="refused"): _run(_simulation()) From 200e1e1e89cf940b63ba8718b0956ddacd153e6c Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 07:09:34 +0000 Subject: [PATCH 41/42] Document the wall-pair index methods as the public API they are neff, neff_minus_one and dneff_domega live on the private _WallPairFill base; they take full numpydoc sections again (frequencies, position, units, shapes), and RectMode and SlabMode name them so that the API reference documents them on the public classes. The private-sections cap rises by those three, public API defined in a private class. Co-Authored-By: Claude Opus 5.5 --- src/solprop/modes/rectangular.py | 53 +++++++++++++++++++++++++++++--- src/solprop/modes/slab.py | 6 ++++ tools/size_budget.toml | 7 +++-- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/solprop/modes/rectangular.py b/src/solprop/modes/rectangular.py index 98c29a4e..3f741f5d 100644 --- a/src/solprop/modes/rectangular.py +++ b/src/solprop/modes/rectangular.py @@ -349,22 +349,59 @@ def polarisation_degenerate(self) -> bool: def neff( self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 ) -> Complex[np.ndarray, " n_omega"]: - """Return the effective index, positive imaginary part for attenuation.""" + """Return the effective index with positive imaginary part for attenuation. + + Parameters + ---------- + omega : numpy.ndarray + Positive frequencies in rad/s, shape ``(n_omega,)``. + z : float, optional + Position in m. + + Returns + ------- + numpy.ndarray + Complex effective index, dimensionless, shape ``(n_omega,)``. + """ return 1 + self._values(omega, z)[0] def neff_minus_one( self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 ) -> Complex[np.ndarray, " n_omega"]: - """Return the refractivity directly from the small square-root argument.""" + """Return the refractivity directly from the small square-root argument. + + Parameters + ---------- + omega : numpy.ndarray + Positive frequencies in rad/s, shape ``(n_omega,)``. + z : float, optional + Position in m. + + Returns + ------- + numpy.ndarray + Complex ``n_eff - 1``, dimensionless, shape ``(n_omega,)``, without + subtracting unity from the index. + """ return self._values(omega, z)[0] def dneff_domega( self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 ) -> Complex[np.ndarray, " n_omega"]: - """Return the analytic derivative, with the core and wall material slopes. + """Return the analytic derivative including core and wall material slopes. + + Parameters + ---------- + omega : numpy.ndarray + Positive frequencies in rad/s, shape ``(n_omega,)``. + z : float, optional + Position in m. - In s/rad; only a slope-free loss callable takes the shared numerical loss - derivative. + Returns + ------- + numpy.ndarray + Complex index derivative in s/rad, shape ``(n_omega,)``; only a + slope-free loss callable uses the shared numerical loss derivative. """ return self._values(omega, z)[1] @@ -446,6 +483,12 @@ class RectMode(_WallPairFill): _gain_warned: bool = field(init=False, default=False, repr=False) _MODEL: ClassVar[str] = "rectangular" + # The index methods are the shared wall-pair base's; named here, so that the API + # reference documents them on this class, as it documents no private base. + neff = _WallPairFill.neff + neff_minus_one = _WallPairFill.neff_minus_one + dneff_domega = _WallPairFill.dneff_domega + def __post_init__(self) -> None: """Validate the mode and resolve geometry and material paths. diff --git a/src/solprop/modes/slab.py b/src/solprop/modes/slab.py index d46dc69c..43ba3a36 100644 --- a/src/solprop/modes/slab.py +++ b/src/solprop/modes/slab.py @@ -201,6 +201,12 @@ class SlabMode(_WallPairFill): _gain_warned: bool = field(init=False, default=False, repr=False) _MODEL: ClassVar[str] = "the slab" + # The index methods are the shared wall-pair base's; named here, so that the API + # reference documents them on this class, as it documents no private base. + neff = _WallPairFill.neff + neff_minus_one = _WallPairFill.neff_minus_one + dneff_domega = _WallPairFill.dneff_domega + def __post_init__(self) -> None: """Validate the mode and resolve the gap and material paths. diff --git a/tools/size_budget.toml b/tools/size_budget.toml index 769f66d6..6d979ede 100644 --- a/tools/size_budget.toml +++ b/tools/size_budget.toml @@ -9,9 +9,10 @@ [private_docstrings] # Private definitions (numpydoc's rule) whose docstring has numpydoc sections # (Parameters, Returns, Yields, Raises, Examples, See Also). The target is 0 (AGENTS.md, -# "Docstrings and comments"). Those left are public API defined in a private module and -# re-exported, which keeps its sections. -max_sectioned = 12 +# "Docstrings and comments"). Those left are public API defined in a private module or +# class and re-exported, which keeps its sections: the three index methods of the +# private wall-pair base that RectMode and SlabMode publish are among them. +max_sectioned = 15 [modules] # No module grows past this many lines. From 80007f9dc43c49f5705f1d5daf9c520374b3c517 Mon Sep 17 00:00:00 2001 From: John Travers Date: Thu, 12 Nov 2026 07:10:24 +0000 Subject: [PATCH 42/42] Call lambda_rdw_all once in shortest_wave Its range defaults to the tool's own when None, so both branches were one call. Co-Authored-By: Claude Opus 5.5 --- src/solprop/service/_curves.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/solprop/service/_curves.py b/src/solprop/service/_curves.py index 4862e3eb..41eee01d 100644 --- a/src/solprop/service/_curves.py +++ b/src/solprop/service/_curves.py @@ -128,24 +128,14 @@ def shortest_wave( """ if beta2 >= 0: return None - roots = ( - lambda_rdw_all( - mode, # pyright: ignore[reportArgumentType] - wavelength, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, # pyright: ignore[reportArgumentType] - ) - if limits is None - else lambda_rdw_all( - mode, # pyright: ignore[reportArgumentType] - wavelength, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, # pyright: ignore[reportArgumentType] - wavelength_limits=limits, - ) + # The tool's own range is its default, so `limits=None` passes through. + roots = lambda_rdw_all( + mode, # pyright: ignore[reportArgumentType] + wavelength, + peak_power=peak_power, + gamma=gamma, + fwhm=fwhm, + shape=shape, # pyright: ignore[reportArgumentType] + wavelength_limits=limits, ) return float(roots[0]) if len(roots) else None